pax_global_header 0000666 0000000 0000000 00000000064 14160146213 0014510 g ustar 00root root 0000000 0000000 52 comment=8c6ed4d73614e0881d8189120cadeabbe7a083e5
.coveragerc 0000664 0000000 0000000 00000000307 14160146213 0013175 0 ustar 00root root 0000000 0000000 [run]
relative_files = True
source=
aioxmpp
omit=
aioxmpp/benchtest/*
aioxmpp/e2etest/*
aioxmpp/_ssl_transport.py
*/python?.?/*
*/python?.?-dev/*
*/dist-packages/*
*/site-packages/*
.github/ 0000775 0000000 0000000 00000000000 14160146213 0012414 5 ustar 00root root 0000000 0000000 .github/workflows/ 0000775 0000000 0000000 00000000000 14160146213 0014451 5 ustar 00root root 0000000 0000000 .github/workflows/main.yaml 0000664 0000000 0000000 00000011517 14160146213 0016266 0 ustar 00root root 0000000 0000000 name: CI
on:
push:
branches:
- devel
- master
- "release-*"
pull_request:
branches:
- devel
- master
- "release-*"
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- '3.5'
- '3.6'
- '3.7'
- '3.8'
- '3.9'
- '3.10'
test-type:
- e2e
e2e-software:
- prosody
e2e-version:
- '0.11'
include:
# e2e-tests for non-default prosody will be run with the Python
# version available in debian stable
- python-version: '3.7'
test-type: e2e
e2e-software: ejabberd
e2e-version: '18.09'
- python-version: '3.7'
test-type: e2e
e2e-software: ejabberd
e2e-version: '19.08'
# plain unit test runs with most recent python version
- python-version: '3.9'
test-type: unit
e2e-software: coveralls
e2e-version: latest
# no proper allow-failure in GitHub actions, so we have to disable
# those instead :(
# (see https://github.com/actions/toolkit/issues/399)
# - python-version: '3.7'
# test-type: e2e
# e2e-software: prosody
# e2e-version: 'trunk'
# - python-version: '3.7'
# test-type: e2e
# e2e-software: metronome
# e2e-version: 'master'
# - python-version: '3.7'
# test-type: e2e
# e2e-software: ejabberd
# e2e-version: latest
name: '${{ matrix.test-type }}: py${{ matrix.python-version }}, ${{ matrix.e2e-software }}@${{ matrix.e2e-version }}'
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '${{ matrix.python-version }}'
- name: Install Prosody
if: matrix.e2e-software == 'prosody'
run: |
set -euo pipefail
export PATH=$PWD/lua_install/bin:$PATH
echo deb http://packages.prosody.im/debian $(lsb_release -sc) main | sudo tee -a /etc/apt/sources.list
wget https://prosody.im/files/prosody-debian-packages.key -O- | sudo apt-key add -
sudo apt-get update
printf '#!/bin/sh\nexit 101\n' | sudo tee /usr/sbin/policy-rc.d
sudo chmod +x /usr/sbin/policy-rc.d
./utils/install-prosody.sh
env:
WITH_BUILD_DEP: "yes"
PROSODY_BRANCH: "${{ matrix.e2e-version }}"
LUA_VERSION: "5.1"
- name: Install Metronome
if: matrix.e2e-software == 'metronome'
run: |
set -euo pipefail
export PATH=$PWD/lua_install/bin:$PATH
# enable source repositories for build dependencies
sudo sed -ri 's/^# deb-src/deb-src/' /etc/apt/sources.list /etc/apt/sources.list.d/*
sudo apt-get update
sudo apt-get install libevent-dev
./utils/install-metronome.sh
env:
WITH_BUILD_DEP: "yes"
METRONOME_VERSION: "${{ matrix.e2e-version }}"
- name: Prepare ejabberd
if: matrix.e2e-software == 'ejabberd'
run: |
set -euo pipefail
./utils/prepare-ejabberd.sh
env:
EJABBERD_VERSION: "${{ matrix.e2e-version }}"
- name: Install aioxmpp and test utils
run: |
set -euo pipefail
pip install pytest pytest-cov coveralls pyOpenSSL
pip install .
- name: Run test suite
run: |
set -euo pipefail
export PATH=$PWD/lua_install/bin:$PATH
if [[ "x$TEST_TYPE" = 'xunit' ]]; then
pytest --cov aioxmpp --cov-report xml tests
else
case "$E2E_SOFTWARE" in
prosody)
export PROSODY_BRANCH="$E2E_VERSION"
./utils/travis-e2etest-prosody.py
;;
metronome)
export METRONOME_VERSION="$E2E_VERSION"
./utils/travis-e2etest-metronome.py
;;
ejabberd)
export EJABBERD_VERSION="$E2E_VERSION"
./utils/travis-e2etest-ejabberd.py
;;
*)
echo "Invalid e2e software: ${E2E_SOFTWARE}" >&2
exit 1
;;
esac
fi
env:
TEST_TYPE: ${{ matrix.test-type }}
E2E_SOFTWARE: ${{ matrix.e2e-software }}
E2E_VERSION: ${{ matrix.e2e-version }}
- name: Coveralls
uses: AndreMiras/coveralls-python-action@develop
if: matrix.test-type == 'unit'
with:
parallel: true
flag-name: unit
finish:
needs: test
runs-on: ubuntu-latest
name: Finalize
steps:
- name: Finalize Coveralls interaction
uses: AndreMiras/coveralls-python-action@develop
with:
parallel-finished: true
.gitignore 0000664 0000000 0000000 00000000107 14160146213 0013042 0 ustar 00root root 0000000 0000000 __pycache__
xmltest.py
dist
aioxmpp.egg-info
.local
.vagrant
.coverage
.mailmap 0000664 0000000 0000000 00000000052 14160146213 0012472 0 ustar 00root root 0000000 0000000 Jonas Schäfer
.travis-pinstore.json 0000664 0000000 0000000 00000000634 14160146213 0015201 0 ustar 00root root 0000000 0000000 {"localhost": ["MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6CZwixsiEk5Mzwi2A6mtdHA/UYTcf3drO6FRXPvr7neHJj4jGkXbj8uCMAlHTY70Is/b3x47YFU07QifQtn9VshMdqj2JAK1VFAEtSeGTDwjs8JBjauuiqw5g45iXZTg/TdtwwX62kajlizE4E502yBUsKf8uF/N0HJxuRelB8vqT1jgGZZegIHzhO4vtqqseTy1t5J8nu3gGAxctR3hd2EXszPW/08BknuDHXDkEuIN9eXCg2X9ANNSTDg+EA0Wu0XeCBuMj7rlMqI5Ld3KxLd5VYSeU+PMiyM30KswQsVx3AqYyCSQtGjETFYkozhPNfJznD9vuJYiv6BCCMUw3wIDAQAB"]}
COPYING.LESSER 0000664 0000000 0000000 00000016743 14160146213 0013116 0 ustar 00root root 0000000 0000000 GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
COPYING.gpl3 0000664 0000000 0000000 00000104513 14160146213 0012757 0 ustar 00root root 0000000 0000000 GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
LICENSES 0000664 0000000 0000000 00000001730 14160146213 0012205 0 ustar 00root root 0000000 0000000 Licenses
========
The whole ``aioxmpp`` is distributed under the LGPLv3. See ``COPYING.gpl3`` together with ``COPYING.LESSER`` for the full license text.
Dependencies
------------
The licenses of the dependencies shall be listed here, too:
* ``pyOpenSSL`` uses Apache 2.0 (see ``docs/licenses/apache20.txt``)
* ``pyasn1`` and ``pyasn1_modules`` use 2-clause BSD (see
``docs/licenses/pyasn1.txt``)
* ``dnspython`` uses a BSD-ish license (see ``docs/licenses/dnspython.txt``)
* ``orderedset`` and ``lxml`` use 3-clause BSD (see
``docs/licenses/orderedset.txt``, ``docs/licenses/lxml.txt``)
* ``tzlocal`` is in the public domain (via CC0 license)
* ``libxml2`` uses the Expat MIT license (see ``docs/licenses/libxml2.txt``)
* ``multidict`` uses the Apache 2.0 license (see ``docs/licenses/apache20.txt``)
* ``aiosasl`` uses the LGPLv3 license (see ``COPYING.gpl3`` and ``COPYING.LESSER``)
* ``aioopenssl`` uses the Apache 2.0 license (see ``docs/licenses/apache20.txt``)
MANIFEST.in 0000664 0000000 0000000 00000000131 14160146213 0012605 0 ustar 00root root 0000000 0000000 include COPYING.gpl3
include COPYING.LESSER
include LICENSES
include docs/licenses/*.txt
Makefile 0000664 0000000 0000000 00000000373 14160146213 0012517 0 ustar 00root root 0000000 0000000 SPHINXBUILD ?= sphinx-build-3
docs-html:
cd docs; $(MAKE) SPHINXBUILD=$(SPHINXBUILD) html
docs-view-html: docs-html
xdg-open docs/sphinx-data/build/html/index.html
docs-clean:
cd docs; $(MAKE) SPHINXBUILD=$(SPHINXBUILD) clean
.PHONY: docs-html
README.rst 0000664 0000000 0000000 00000013232 14160146213 0012544 0 ustar 00root root 0000000 0000000 ``aioxmpp``
###########
.. image:: https://travis-ci.org/horazont/aioxmpp.svg?branch=devel
:target: https://travis-ci.org/horazont/aioxmpp
.. image:: https://coveralls.io/repos/github/horazont/aioxmpp/badge.svg?branch=devel
:target: https://coveralls.io/github/horazont/aioxmpp?branch=devel
.. image:: https://img.shields.io/pypi/v/aioxmpp.svg
:target: https://pypi.python.org/pypi/aioxmpp/
... is a pure-python XMPP library using the `asyncio`_ standard library module from Python 3.4 (and `available as a third-party module to Python 3.3`__).
.. _asyncio: https://docs.python.org/3/library/asyncio.html
__ https://code.google.com/p/tulip/
.. remember to update the feature list in the docs
Features
========
* Native `Stream Management (XEP-0198)
`_ support for robustness against
transient network failures (such as switching between wireless and wired
networks).
* Powerful declarative-style definition of XEP-based and custom protocols. Most
of the time, you will not get in contact with raw XML or character data, even
when implementing a new protocol.
* Secure by default: TLS is required by default, as well as certificate
validation. Certificate or public key pinning can be used, if needed.
* Support for `RFC 6121 (Instant Messaging and Presence)
`_ roster and presence management, along
with `XEP-0045 (Multi-User Chats)
`_ for your human-to-human needs.
* Support for `XEP-0060 (Publish-Subscribe)
`_ and `XEP-0050 (Ad-Hoc Commands)
`_ for your machine-to-machine
needs.
* Several other XEPs, such as `XEP-0115
`_ (including native support for
the reading and writing the `capsdb `_) and
`XEP-0131 `_.
* APIs suitable for both one-shot scripts and long-running multi-account
clients.
* Well-tested and modular codebase: aioxmpp is developed in test-driven
style and in addition to that, many modules are automatedly tested against
`Prosody `_ and `ejabberd `_,
two popular XMPP servers.
There is more and there’s yet more to come! Check out the list of supported XEPs
in the `official documentation`_ and `open GitHub issues tagged as enhancement
`_
for things which are planned and read on below on how to contribute.
Documentation
=============
The ``aioxmpp`` API is thoroughly documented using Sphinx. Check out the `official documentation`_ for a `quick start`_ and the `API reference`_.
Dependencies
============
* Python ≥ 3.4 (or Python = 3.3 with tulip and enum34)
* DNSPython
* lxml
* `sortedcollections`__
__ https://pypi.python.org/pypi/sortedcollections
* `tzlocal`__ (for i18n support)
__ https://pypi.python.org/pypi/tzlocal
* `pyOpenSSL`__
__ https://pypi.python.org/pypi/pyOpenSSL
* `pyasn1`_ and `pyasn1_modules`__
.. _pyasn1: https://pypi.python.org/pypi/pyasn1
__ https://pypi.python.org/pypi/pyasn1-modules
* `aiosasl`__ (≥ 0.3 for ``ANONYMOUS`` support)
__ https://pypi.python.org/pypi/aiosasl
* `multidict`__
__ https://pypi.python.org/pypi/multidict
* `aioopenssl`__
__ https://github.com/horazont/aioopenssl
* `typing`__ (Python < 3.5 only)
__ https://pypi.python.org/pypi/typing
Contributing
============
If you consider contributing to aioxmpp, you can do so, even without a GitHub
account. There are several ways to get in touch with the aioxmpp developer(s):
* `The development mailing list
`_. Feel
free to subscribe and post, but be polite and adhere to the `Netiquette
(RFC 1855) `_. Pull requests posted to
the mailing list are also welcome!
* The development MUC at ``aioxmpp@conference.zombofant.net``. Pull requests
announced in the MUC are also welcome! Note that the MUC is set persistent,
but nevertheless there may not always be people around. If in doubt, use the
mailing list instead.
* Open or comment on an issue or post a pull request on `GitHub
`_.
No idea what to do, but still want to get your hands dirty? Check out the list
of `'help wanted' issues on GitHub
`_
or ask in the MUC or on the mailing list. The issues tagged as 'help wanted' are
usually of narrow scope, aimed at beginners.
Be sure to read the ``docs/CONTRIBUTING.rst`` for some hints on how to
author your contribution.
Security issues
---------------
If you believe that a bug you found in aioxmpp has security implications,
you are welcome to notify me privately. To do so, send a mail to `Jonas Schäfer
`_, encrypted using the GPG public key
0xE5EDE5AC679E300F (Fingerprint AA5A 78FF 508D 8CF4 F355 F682 E5ED E5AC 679E
300F).
If you prefer to disclose security issues immediately, you can do so at any of
the places listed above.
More details can be found in the `SECURITY.md `_ file.
Change log
==========
The `change log`_ is included in the `official documentation`_.
.. _change log: https://docs.zombofant.net/aioxmpp/0.13/api/changelog.html
.. _official documentation: https://docs.zombofant.net/aioxmpp/0.13/
.. _quick start: https://docs.zombofant.net/aioxmpp/0.13/user-guide/quickstart.html
.. _API reference: https://docs.zombofant.net/aioxmpp/0.13/api/index.html
SECURITY.md 0000664 0000000 0000000 00000002434 14160146213 0012650 0 ustar 00root root 0000000 0000000 # Security Policy
## Supported Versions
This is an overview of the security support status of aioxmpp
releases. For the supported versions, we provide backports of security
relevant patches ASAP after we become aware of them.
**Note:** Distributors of aioxmpp may also support older versions.
| Version | Supported |
| -------- | ------------------ |
| 0.10.x | :white_check_mark: |
| < 0.10.0 | :x: |
## Reporting a Vulnerability
To report a vulnerability, you can send a GPG encrypted message to the
main maintainer of aioxmpp, [Jonas Schäfer](mailto:jonas@wielicki.name).
The GPG key ID is 0xE5EDE5AC679E300F (full fingerprint: AA5A 78FF 508D
8CF4 F355 F682 E5ED E5AC 679E 300F).
If you prefer to report vulnerabilities publicly right away, you can do
so like you would report normal issues; that is, here on GitHub, in our
chat room or on the mailing list (see the README for details).
When you report a security vulnerability, we will handle it with the
highest priority and work, possibly with you, to create a fix. Please
provide as much information as possible in the initial report so that
we can get to the core of the issue right away.
We will keep you posted on progress of fixing the issue via a
communication channel we negotiate when you first report it.
aioxmpp/ 0000775 0000000 0000000 00000000000 14160146213 0012531 5 ustar 00root root 0000000 0000000 aioxmpp/__init__.py 0000664 0000000 0000000 00000010663 14160146213 0014650 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
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
aioxmpp/_version.py 0000664 0000000 0000000 00000002101 14160146213 0014721 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
version_info = (0, 13, 1, None)
__version__ = ".".join(map(str, version_info[:3])) + ("-"+version_info[3] if
version_info[3] else "")
version = __version__
aioxmpp/adhoc/ 0000775 0000000 0000000 00000000000 14160146213 0013607 5 ustar 00root root 0000000 0000000 aioxmpp/adhoc/__init__.py 0000664 0000000 0000000 00000003620 14160146213 0015721 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
aioxmpp/adhoc/service.py 0000664 0000000 0000000 00000045325 14160146213 0015632 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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
```` 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()
aioxmpp/adhoc/xso.py 0000664 0000000 0000000 00000013041 14160146213 0014771 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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"),
)
aioxmpp/avatar/ 0000775 0000000 0000000 00000000000 14160146213 0014007 5 ustar 00root root 0000000 0000000 aioxmpp/avatar/__init__.py 0000664 0000000 0000000 00000005354 14160146213 0016127 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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)
aioxmpp/avatar/service.py 0000664 0000000 0000000 00000102733 14160146213 0016027 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import hashlib
import logging
import warnings
import aioxmpp
import aioxmpp.callbacks as callbacks
import aioxmpp.service as service
import aioxmpp.disco as disco
import aioxmpp.pep as pep
import aioxmpp.presence as presence
import aioxmpp.pubsub as pubsub
import aioxmpp.vcard as vcard
from aioxmpp.cache import LRUDict
from aioxmpp.utils import namespaces, gather_reraise_multi
from . import xso as avatar_xso
logger = logging.getLogger(__name__)
def normalize_id(id_):
"""
Normalize a SHA1 sum encoded as hexadecimal number in ASCII.
This does nothing but lowercase the string as to enable robust
comparison.
"""
return id_.lower()
class AvatarSet:
"""
A list of sources of an avatar.
Exactly one of the sources must include image data in the
``image/png`` format. The others provide the location of the
image data as an URL.
Adding pointer avatar data is not yet supported.
.. automethod:: add_avatar_image
"""
def __init__(self):
self._image_bytes = None
self._png_id = None
self._metadata = avatar_xso.Metadata()
@property
def image_bytes(self):
"""
The image data bytes for MIME type ``text/png``.
"""
return self._image_bytes
@property
def metadata(self):
"""
The :class:`Metadata` XSO corresponding to this avatar set.
"""
return self._metadata
@property
def png_id(self):
"""
The SHA1 of the ``image/png`` image data.
This id is always normalized in the sense of :function:`normalize_id`.
"""
return self._png_id
def add_avatar_image(self, mime_type, *, id_=None,
image_bytes=None, width=None, height=None,
url=None, nbytes=None):
"""
Add a source of the avatar image.
All sources of an avatar image added to an avatar set must be
*the same image*, in different formats and sizes.
:param mime_type: The MIME type of the avatar image.
:param id_: The SHA1 of the image data.
:param nbytes: The size of the image data in bytes.
:param image_bytes: The image data, this must be supplied only
in one call.
:param url: The URL of the avatar image.
:param height: The height of the image in pixels (optional).
:param width: The width of the image in pixels (optional).
`id_` and `nbytes` may be omitted if and only if `image_data`
is given and `mime_type` is ``image/png``. If they are
supplied *and* image data is given, they are checked to match
the image data.
It is the caller's responsibility to assure that the provided
links exist and the files have the correct SHA1 sums.
"""
if mime_type == "image/png":
if image_bytes is not None:
if self._image_bytes is not None:
raise RuntimeError(
"Only one avatar image may be published directly."
)
sha1 = hashlib.sha1()
sha1.update(image_bytes)
id_computed = normalize_id(sha1.hexdigest())
if id_ is not None:
id_ = normalize_id(id_)
if id_ != id_computed:
raise RuntimeError(
"The given id does not match the SHA1 of "
"the image data."
)
else:
id_ = id_computed
nbytes_computed = len(image_bytes)
if nbytes is not None:
if nbytes != nbytes_computed:
raise RuntimeError(
"The given length does not match the length "
"of the image data."
)
else:
nbytes = nbytes_computed
self._image_bytes = image_bytes
self._png_id = id_
if image_bytes is None and url is None:
raise RuntimeError(
"Either the image bytes or an url to retrieve the avatar "
"image must be given."
)
if nbytes is None:
raise RuntimeError(
"Image data length is not given an not inferable "
"from the other arguments."
)
if id_ is None:
raise RuntimeError(
"The SHA1 of the image data is not given an not inferable "
"from the other arguments."
)
if image_bytes is not None and mime_type != "image/png":
raise RuntimeError(
"The image bytes can only be given for image/png data."
)
self._metadata.info[mime_type].append(
avatar_xso.Info(
id_=id_, mime_type=mime_type, nbytes=nbytes,
width=width, height=height, url=url
)
)
class AbstractAvatarDescriptor:
"""
Description of the properties of and how to retrieve a specific
avatar.
The following attributes are available for all instances:
.. autoattribute:: remote_jid
.. autoattribute:: id_
.. autoattribute:: normalized_id
.. autoattribute:: can_get_image_bytes_via_xmpp
.. autoattribute:: has_image_data_in_pubsub
The following attributes may be :data:`None` and are supposed to
be used as hints for selection of the avatar to download:
.. autoattribute:: nbytes
.. autoattribute:: width
.. autoattribute:: height
.. autoattribute:: mime_type
If this attribute is not :data:`None` it is an URL that points to
the location of the avatar image:
.. autoattribute:: url
The image data belonging to the descriptor can be retrieved by the
following coroutine:
.. automethod:: get_image_bytes
"""
def __init__(self, remote_jid, id_, *, mime_type=None,
nbytes=None, width=None, height=None, url=None):
self._remote_jid = remote_jid
self._mime_type = mime_type
self._id = id_
self._nbytes = nbytes
self._width = width
self._height = height
self._url = url
def __eq__(self, other):
return (self._remote_jid == other._remote_jid and
self._mime_type == other._mime_type and
self._id == other._id and
self._nbytes == other._nbytes and
self._width == other._width and
self._height == other._height and
self._url == other._url)
async def get_image_bytes(self):
"""
Try to retrieve the image data corresponding to this avatar
descriptor.
:returns: the image contents
:rtype: :class:`bytes`
:raises NotImplementedError: if we do not implement the
capability to retrieve the image data of this type. It is
guaranteed to not raise :class:`NotImplementedError` if
:attr:`can_get_image_bytes_via_xmpp` is true.
:raises RuntimeError: if the image data described by this
descriptor is not at the specified location.
:raises aiomxpp.XMPPCancelError: if trying to retrieve the
image data causes an XMPP error.
"""
raise NotImplementedError
@property
def can_get_image_bytes_via_xmpp(self):
"""
Return whether :meth:`get_image_bytes` raises
:class:`NotImplementedError`.
"""
return False
@property
def has_image_data_in_pubsub(self):
"""
Whether the image can be retrieved from PubSub.
.. deprecated:: 0.10
Use :attr:`can_get_image_bytes_via_xmpp` instead.
As we support vCard based avatars now the name of this is
misleading.
This attribute will be removed in aioxmpp 1.0
"""
warnings.warn(
"the has_image_data_in_pubsub attribute is deprecated and will be"
" removed in 1.0",
DeprecationWarning,
stacklevel=1
)
return self.can_get_image_bytes_via_xmpp
@property
def remote_jid(self):
"""
The remote JID this avatar belongs to.
"""
return self._remote_jid
@property
def url(self):
"""
The URL where the avatar image data can be found.
This may be :data:`None` if the avatar is not given as an URL
of the image data.
"""
return self._url
@property
def width(self):
"""
The width of the avatar image in pixels.
This is :data:`None` if this information is not supplied.
"""
return self._width
@property
def height(self):
"""
The height of the avatar image in pixels.
This is :data:`None` if this information is not supplied.
"""
return self._height
@property
def nbytes(self):
"""
The size of the avatar image data in bytes.
"""
return self._nbytes
@property
def id_(self):
"""
The SHA1 of the image encoded as hexadecimal number in ASCII.
This is the original value returned from the underlying
protocol and should be used for any further interaction with
the underlying protocol.
"""
return self._id
@property
def normalized_id(self):
"""
The normalized SHA1 of the image data.
This is supposed to be used for caching and comparison.
"""
return normalize_id(self._id)
@property
def mime_type(self):
"""
The MIME type of the image data.
"""
return self._mime_type
class PubsubAvatarDescriptor(AbstractAvatarDescriptor):
def __init__(self, remote_jid, id_, *, pubsub=None, **kwargs):
super().__init__(remote_jid, id_, **kwargs)
self._pubsub = pubsub
def __eq__(self, other):
return (isinstance(other, PubsubAvatarDescriptor) and
super().__eq__(other))
@property
def can_get_image_bytes_via_xmpp(self):
return True
async def get_image_bytes(self):
image_data = await self._pubsub.get_items_by_id(
self._remote_jid,
namespaces.xep0084_data,
[self.id_],
)
if not image_data.payload.items:
raise RuntimeError("Avatar image data is not set.")
item, = image_data.payload.items
return item.registered_payload.data
class HttpAvatarDescriptor(AbstractAvatarDescriptor):
async def get_image_bytes(self):
raise NotImplementedError
def __eq__(self, other):
return (isinstance(other, HttpAvatarDescriptor) and
super().__eq__(other))
class VCardAvatarDescriptor(AbstractAvatarDescriptor):
def __init__(self, remote_jid, id_, *, vcard=None, image_bytes=None,
**kwargs):
super().__init__(remote_jid, id_, **kwargs)
self._vcard = vcard
self._image_bytes = image_bytes
def __eq__(self, other):
# NOTE: we explicitly do *not* check for the equality of
# image bytes: image bytes is a hidden optimization
return (isinstance(other, VCardAvatarDescriptor) and
super().__eq__(other))
@property
def can_get_image_bytes_via_xmpp(self):
return True
async def get_image_bytes(self):
if self._image_bytes is not None:
return self._image_bytes
logger.debug("retrieving vCard %s", self._remote_jid)
vcard = await self._vcard.get_vcard(self._remote_jid)
photo = vcard.get_photo_data()
if photo is None:
raise RuntimeError("Avatar image is not set")
logger.debug("returning vCard avatar %s", self._remote_jid)
return photo
class AvatarService(service.Service):
"""
Access and publish User Avatars (:xep:`84`). Fallback to vCard
based avatars (:xep:`153`) if no PEP avatar is available.
This service provides an interface for accessing the avatar of other
entities in the network, getting notifications on avatar changes and
publishing an avatar for this entity.
.. versionchanged:: 0.10
Support for :xep:`vCard-Based Avatars <153>` was added.
Observing avatars:
.. note:: :class:`AvatarService` only caches the metadata, not the
actual image data. This is the job of the caller.
.. signal:: on_metadata_changed(jid, metadata)
Fires when avatar metadata changes.
:param jid: The JID which the avatar belongs to.
:param metadata: The new metadata descriptors.
:type metadata: a sequence of
:class:`~aioxmpp.avatar.service.AbstractAvatarDescriptor`
instances
.. automethod:: get_avatar_metadata
.. automethod:: subscribe
Publishing avatars:
.. automethod:: publish_avatar_set
.. automethod:: disable_avatar
.. automethod:: wipe_avatar
Configuration:
.. autoattribute:: synchronize_vcard
.. autoattribute:: advertise_vcard
.. attribute:: avatar_pep
The PEP descriptor for claiming the avatar metadata namespace.
The value is a :class:`~aioxmpp.pep.service.RegisteredPEPNode`,
whose :attr:`~aioxmpp.pep.service.RegisteredPEPNode.notify`
property can be used to disable or enable the notification
feature.
.. autoattribute:: metadata_cache_size
:annotation: = 200
"""
ORDER_AFTER = [
disco.DiscoClient,
disco.DiscoServer,
pubsub.PubSubClient,
pep.PEPClient,
vcard.VCardService,
presence.PresenceClient,
presence.PresenceServer,
]
avatar_pep = pep.register_pep_node(
namespaces.xep0084_metadata,
notify=True,
)
on_metadata_changed = callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._has_pep_avatar = set()
self._metadata_cache = LRUDict()
self._metadata_cache.maxsize = 200
self._pubsub = self.dependencies[pubsub.PubSubClient]
self._pep = self.dependencies[pep.PEPClient]
self._presence_server = self.dependencies[presence.PresenceServer]
self._disco = self.dependencies[disco.DiscoClient]
self._vcard = self.dependencies[vcard.VCardService]
# we use this lock to prevent race conditions between different
# calls of the methods by one client.
# XXX: Other, independent clients may still cause inconsistent
# data by race conditions, this should be fixed by at least
# checking for consistent data after an update.
self._publish_lock = asyncio.Lock()
self._synchronize_vcard = False
self._advertise_vcard = True
self._vcard_resource_interference = set()
self._vcard_id = None
self._vcard_rehashing_for = None
self._vcard_rehash_task = None
@property
def metadata_cache_size(self):
"""
Maximum number of cache entries in the avatar metadata cache.
This is mostly a measure to prevent malicious peers from
exhausting memory by spamming vCard based avatar metadata for
different resources.
.. versionadded:: 0.10
"""
return self._metadata_cache.maxsize
@metadata_cache_size.setter
def metadata_cache_size(self, value):
self._metadata_cache.maxsize = value
@property
def synchronize_vcard(self):
"""
Set this property to true to enable publishing the a vCard avatar.
This property defaults to false. For the setting true to have
effect, you have to publish your avatar with :meth:`publish_avatar_set`
or :meth:`disable_avatar` *after* this switch has been set to true.
"""
return self._synchronize_vcard
@synchronize_vcard.setter
def synchronize_vcard(self, value):
self._synchronize_vcard = bool(value)
@property
def advertise_vcard(self):
"""
Set this property to false to disable advertisement of the vCard
avatar via presence broadcast.
Note, that this reduces traffic, since it makes the presence
stanzas smaller and we no longer have to recalculate the hash,
this also disables vCard advertisement for all other
resources of the bare local jid, by the business rules of
:xep:`0153`.
Note that, when enabling this feature again the vCard has to
be fetched from the server to recalculate the hash.
"""
return self._advertise_vcard
@advertise_vcard.setter
def advertise_vcard(self, value):
self._advertise_vcard = bool(value)
if self._advertise_vcard:
self._vcard_id = None
self._start_rehash_task()
@service.depfilter(aioxmpp.stream.StanzaStream,
"service_outbound_presence_filter")
def _attach_vcard_notify_to_presence(self, stanza):
if self._advertise_vcard:
if self._vcard_resource_interference:
# do not advertise the hash if there is resource interference
stanza.xep0153_x = avatar_xso.VCardTempUpdate()
else:
stanza.xep0153_x = avatar_xso.VCardTempUpdate(self._vcard_id)
return stanza
def _update_metadata(self, cache_jid, metadata):
try:
cached_metadata = self._metadata_cache[cache_jid]
except KeyError:
pass
else:
if cached_metadata == metadata:
return
self._metadata_cache[cache_jid] = metadata
self.on_metadata_changed(
cache_jid,
metadata
)
def _handle_notify(self, full_jid, stanza):
# handle resource interference as per XEP-153 business rules,
# we go along with this tracking even if vcard advertisement
# is off
if (full_jid.bare() == self.client.local_jid.bare() and
full_jid != self.client.local_jid):
if stanza.xep0153_x is None:
self._vcard_resource_interference.add(full_jid)
else:
if self._vcard_resource_interference:
self._vcard_resource_interference.discard(full_jid)
if not self._vcard_resource_interference:
self._vcard_id = None
# otherwise ignore stanzas without xep0153_x payload, or
# no photo tag.
if stanza.xep0153_x is None:
return
if stanza.xep0153_x.photo is None:
return
# special case MUC presence – otherwise the vcard is retrieved
# for the bare jid
if stanza.xep0045_muc_user is not None:
cache_jid = full_jid
else:
cache_jid = full_jid.bare()
if cache_jid not in self._has_pep_avatar:
metadata = self._cook_vcard_notify(cache_jid, stanza)
self._update_metadata(cache_jid, metadata)
# trigger the download of the vCard and calculation of the
# vCard avatar hash, if some other resource of our bare jid
# reported a hash distinct from ours!
# don't do this if there is a non-compliant resource, we don't
# send the hash in that case anyway
if (full_jid.bare() == self.client.local_jid.bare() and
full_jid != self.client.local_jid and
self._advertise_vcard and
not self._vcard_resource_interference):
if (self._vcard_id is None or
stanza.xep0153_x.photo.lower() !=
self._vcard_id.lower()):
# do not rehash if we already have a rehash task that
# was triggered by an update with the same hash
if (self._vcard_rehashing_for is None or
self._vcard_rehashing_for !=
stanza.xep0153_x.photo.lower()):
self._vcard_rehashing_for = stanza.xep0153_x.photo.lower()
self._start_rehash_task()
def _start_rehash_task(self):
if self._vcard_rehash_task is not None:
self._vcard_rehash_task.cancel()
self._vcard_id = None
# as per XEP immediately resend the presence with empty update
# element, as this is not synchronous it might already contaiin
# the new hash, but this is okay as well (as it makes the cached
# presence stanzas coherent as well).
self._presence_server.resend_presence()
self._vcard_rehash_task = asyncio.ensure_future(
self._calculate_vcard_id()
)
def set_new_vcard_id(fut):
self._vcard_rehashing_for = None
if not fut.cancelled():
self._vcard_id = fut.result()
self._vcard_rehash_task.add_done_callback(
set_new_vcard_id
)
async def _calculate_vcard_id(self):
self.logger.debug("updating vcard hash")
vcard = await self._vcard.get_vcard()
self.logger.debug("got vcard for hash update: %s", vcard)
photo = vcard.get_photo_data()
# if no photo is set in the vcard, set an empty element
# in the update; according to the spec this means the avatar
# is disabled
if photo is None:
self.logger.debug("no photo in vcard, advertising as such")
return ""
sha1 = hashlib.sha1()
sha1.update(photo)
new_hash = sha1.hexdigest().lower()
self.logger.debug("updated hash to %s", new_hash)
return new_hash
@service.depsignal(presence.PresenceClient, "on_available")
def _handle_on_available(self, full_jid, stanza):
self._handle_notify(full_jid, stanza)
@service.depsignal(presence.PresenceClient, "on_changed")
def _handle_on_changed(self, full_jid, stanza):
self._handle_notify(full_jid, stanza)
@service.depsignal(presence.PresenceClient, "on_unavailable")
def _handle_on_unavailable(self, full_jid, stanza):
if full_jid.bare() == self.client.local_jid.bare():
if self._vcard_resource_interference:
self._vcard_resource_interference.discard(full_jid)
if not self._vcard_resource_interference:
self._start_rehash_task()
# correctly handle MUC avatars
if stanza.xep0045_muc_user is not None:
self._metadata_cache.pop(full_jid, None)
def _cook_vcard_notify(self, jid, stanza):
result = []
# note: an empty photo element correctly
# results in an empty avatar metadata list
if stanza.xep0153_x.photo:
result.append(
VCardAvatarDescriptor(
remote_jid=jid,
id_=stanza.xep0153_x.photo,
mime_type=None,
vcard=self._vcard,
nbytes=None,
)
)
return result
def _cook_metadata(self, jid, items):
def iter_metadata_info_nodes(items):
for item in items:
yield from item.registered_payload.iter_info_nodes()
result = []
for info_node in iter_metadata_info_nodes(items):
if info_node.url is not None:
descriptor = HttpAvatarDescriptor(
remote_jid=jid,
id_=info_node.id_,
mime_type=info_node.mime_type,
nbytes=info_node.nbytes,
width=info_node.width,
height=info_node.height,
url=info_node.url,
)
else:
descriptor = PubsubAvatarDescriptor(
remote_jid=jid,
id_=info_node.id_,
mime_type=info_node.mime_type,
nbytes=info_node.nbytes,
width=info_node.width,
height=info_node.height,
pubsub=self._pubsub,
)
result.append(descriptor)
return result
@service.attrsignal(avatar_pep, "on_item_publish")
def _handle_pubsub_publish(self, jid, node, item, *, message=None):
# update the metadata cache
metadata = self._cook_metadata(jid, [item])
self._has_pep_avatar.add(jid)
self._update_metadata(jid, metadata)
async def _get_avatar_metadata_vcard(self, jid):
logger.debug("trying vCard avatar as fallback for %s", jid)
vcard = await self._vcard.get_vcard(jid)
photo = vcard.get_photo_data()
mime_type = vcard.get_photo_mime_type()
if photo is None:
return []
logger.debug("success vCard avatar as fallback for %s",
jid)
sha1 = hashlib.sha1()
sha1.update(photo)
return [VCardAvatarDescriptor(
remote_jid=jid,
id_=sha1.hexdigest(),
mime_type=mime_type,
nbytes=len(photo),
vcard=self._vcard,
image_bytes=photo,
)]
async def _get_avatar_metadata_pep(self, jid):
try:
metadata_raw = await self._pubsub.get_items(
jid,
namespaces.xep0084_metadata,
max_items=1
)
except aioxmpp.XMPPCancelError as e:
# transparently map feature-not-implemented and
# item-not-found to be equivalent unset avatar
if e.condition in (
aioxmpp.ErrorCondition.FEATURE_NOT_IMPLEMENTED,
aioxmpp.ErrorCondition.ITEM_NOT_FOUND):
return []
raise
self._has_pep_avatar.add(jid)
return self._cook_metadata(jid, metadata_raw.payload.items)
async def get_avatar_metadata(self, jid, *, require_fresh=False,
disable_pep=False):
"""
Retrieve a list of avatar descriptors.
:param jid: the JID for which to retrieve the avatar metadata.
:type jid: :class:`aioxmpp.JID`
:param require_fresh: if true, do not return results from the
avatar metadata cache, but retrieve them again from the server.
:type require_fresh: :class:`bool`
:param disable_pep: if true, do not try to retrieve the avatar
via pep, only try the vCard fallback. This usually only
useful when querying avatars via MUC, where the PEP request
would be invalid (since it would be for a full jid).
:type disable_pep: :class:`bool`
:returns: an iterable of avatar descriptors.
:rtype: a :class:`list` of
:class:`~aioxmpp.avatar.service.AbstractAvatarDescriptor`
instances
Returning an empty list means that the avatar not set.
We mask a :class:`XMPPCancelError` in the case that it is
``feature-not-implemented`` or ``item-not-found`` and return
an empty list of avatar descriptors, since this is
semantically equivalent to not having an avatar.
.. note::
It is usually an error to get the avatar for a full jid,
normally, the avatar is set for the bare jid of a user. The
exception are vCard avatars over MUC, where the IQ requests
for the vCard may be translated by the MUC server. It is
recommended to use the `disable_pep` option in that case.
"""
if require_fresh:
self._metadata_cache.pop(jid, None)
else:
try:
return self._metadata_cache[jid]
except KeyError:
pass
if disable_pep:
metadata = []
else:
metadata = await self._get_avatar_metadata_pep(jid)
# try the vcard fallback, note: we don't try this
# if the PEP avatar is disabled!
if not metadata and jid not in self._has_pep_avatar:
metadata = await self._get_avatar_metadata_vcard(jid)
# if a notify was fired while we waited for the results, then
# use the version in the cache, this will mitigate the race
# condition because if our version is actually newer we will
# soon get another notify for this version change!
if jid not in self._metadata_cache:
self._update_metadata(jid, metadata)
return self._metadata_cache[jid]
async def subscribe(self, jid):
"""
Explicitly subscribe to metadata change notifications for `jid`.
"""
await self._pubsub.subscribe(jid, namespaces.xep0084_metadata)
@aioxmpp.service.depsignal(aioxmpp.stream.StanzaStream,
"on_stream_destroyed")
def handle_stream_destroyed(self, reason):
self._metadata_cache.clear()
self._vcard_resource_interference.clear()
self._has_pep_avatar.clear()
async def publish_avatar_set(self, avatar_set):
"""
Make `avatar_set` the current avatar of the jid associated with this
connection.
If :attr:`synchronize_vcard` is true and PEP is available the
vCard is only synchronized if the PEP update is successful.
This means publishing the ``image/png`` avatar data and the
avatar metadata set in pubsub. The `avatar_set` must be an
instance of :class:`AvatarSet`. If :attr:`synchronize_vcard` is
true the avatar is additionally published in the user vCard.
"""
id_ = avatar_set.png_id
done = False
async with self._publish_lock:
if await self._pep.available():
await self._pep.publish(
namespaces.xep0084_data,
avatar_xso.Data(avatar_set.image_bytes),
id_=id_
)
await self._pep.publish(
namespaces.xep0084_metadata,
avatar_set.metadata,
id_=id_
)
done = True
if self._synchronize_vcard:
my_vcard = await self._vcard.get_vcard()
my_vcard.set_photo_data("image/png",
avatar_set.image_bytes)
self._vcard_id = avatar_set.png_id
await self._vcard.set_vcard(my_vcard)
self._presence_server.resend_presence()
done = True
if not done:
raise RuntimeError(
"failed to publish avatar: no protocol available"
)
async def _disable_vcard_avatar(self):
my_vcard = await self._vcard.get_vcard()
my_vcard.clear_photo_data()
self._vcard_id = ""
await self._vcard.set_vcard(my_vcard)
self._presence_server.resend_presence()
async def disable_avatar(self):
"""
Temporarily disable the avatar.
If :attr:`synchronize_vcard` is true, the vCard avatar is
disabled (even if disabling the PEP avatar fails).
This is done by setting the avatar metadata node empty and if
:attr:`synchronize_vcard` is true, downloading the vCard,
removing the avatar data and re-uploading the vCard.
This method does not error if neither protocol is active.
:raises aioxmpp.errors.GatherError: if an exception is raised
by the spawned tasks.
"""
async with self._publish_lock:
todo = []
if self._synchronize_vcard:
todo.append(self._disable_vcard_avatar())
if await self._pep.available():
todo.append(self._pep.publish(
namespaces.xep0084_metadata,
avatar_xso.Metadata()
))
await gather_reraise_multi(*todo, message="disable_avatar")
async def wipe_avatar(self):
"""
Remove all avatar data stored on the server.
If :attr:`synchronize_vcard` is true, the vCard avatar is
disabled even if disabling the PEP avatar fails.
This is equivalent to :meth:`disable_avatar` for vCard-based
avatars, but will also remove the data PubSub node for
PEP avatars.
This method does not error if neither protocol is active.
:raises aioxmpp.errors.GatherError: if an exception is raised
by the spawned tasks.
"""
async def _wipe_pep_avatar():
await self._pep.publish(
namespaces.xep0084_metadata,
avatar_xso.Metadata()
)
await self._pep.publish(
namespaces.xep0084_data,
avatar_xso.Data(b'')
)
async with self._publish_lock:
todo = []
if self._synchronize_vcard:
todo.append(self._disable_vcard_avatar())
if await self._pep.available():
todo.append(_wipe_pep_avatar())
await gather_reraise_multi(*todo, message="wipe_avatar")
aioxmpp/avatar/xso.py 0000664 0000000 0000000 00000013075 14160146213 0015200 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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
aioxmpp/benchtest/ 0000775 0000000 0000000 00000000000 14160146213 0014510 5 ustar 00root root 0000000 0000000 aioxmpp/benchtest/__init__.py 0000664 0000000 0000000 00000020444 14160146213 0016625 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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)
aioxmpp/benchtest/__main__.py 0000664 0000000 0000000 00000001721 14160146213 0016603 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import nose
from aioxmpp.benchtest import BenchmarkPlugin
nose.main(addplugins=[BenchmarkPlugin()])
aioxmpp/blocking/ 0000775 0000000 0000000 00000000000 14160146213 0014321 5 ustar 00root root 0000000 0000000 aioxmpp/blocking/__init__.py 0000664 0000000 0000000 00000002444 14160146213 0016436 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
aioxmpp/blocking/service.py 0000664 0000000 0000000 00000016776 14160146213 0016354 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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
aioxmpp/blocking/xso.py 0000664 0000000 0000000 00000007414 14160146213 0015512 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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 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()
)
aioxmpp/bookmarks/ 0000775 0000000 0000000 00000000000 14160146213 0014521 5 ustar 00root root 0000000 0000000 aioxmpp/bookmarks/__init__.py 0000664 0000000 0000000 00000004215 14160146213 0016634 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
aioxmpp/bookmarks/service.py 0000664 0000000 0000000 00000043117 14160146213 0016541 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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)
aioxmpp/bookmarks/xso.py 0000664 0000000 0000000 00000015741 14160146213 0015714 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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
aioxmpp/cache.py 0000664 0000000 0000000 00000011445 14160146213 0014153 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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()
aioxmpp/callbacks.py 0000664 0000000 0000000 00000065235 14160146213 0015035 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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 ` 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
aioxmpp/carbons/ 0000775 0000000 0000000 00000000000 14160146213 0014160 5 ustar 00root root 0000000 0000000 aioxmpp/carbons/__init__.py 0000664 0000000 0000000 00000004044 14160146213 0016273 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
aioxmpp/carbons/service.py 0000664 0000000 0000000 00000006406 14160146213 0016200 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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)
aioxmpp/carbons/xso.py 0000664 0000000 0000000 00000005671 14160146213 0015354 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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])
aioxmpp/chatstates/ 0000775 0000000 0000000 00000000000 14160146213 0014674 5 ustar 00root root 0000000 0000000 aioxmpp/chatstates/__init__.py 0000664 0000000 0000000 00000003463 14160146213 0017013 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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)
aioxmpp/chatstates/utils.py 0000664 0000000 0000000 00000010053 14160146213 0016405 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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()
aioxmpp/chatstates/xso.py 0000664 0000000 0000000 00000003066 14160146213 0016064 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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)
aioxmpp/connector.py 0000664 0000000 0000000 00000027412 14160146213 0015103 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
aioxmpp/custom_queue.py 0000664 0000000 0000000 00000004244 14160146213 0015625 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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()
aioxmpp/disco/ 0000775 0000000 0000000 00000000000 14160146213 0013632 5 ustar 00root root 0000000 0000000 aioxmpp/disco/__init__.py 0000664 0000000 0000000 00000006406 14160146213 0015751 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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)
aioxmpp/disco/service.py 0000664 0000000 0000000 00000101515 14160146213 0015647 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import contextlib
import functools
import itertools
import aioxmpp.cache
import aioxmpp.callbacks
import aioxmpp.errors as errors
import aioxmpp.service as service
import aioxmpp.structs as structs
import aioxmpp.stanza as stanza
from aioxmpp.utils import namespaces
from . import xso as disco_xso
class Node(object):
"""
A :class:`Node` holds the information related to a specific node within the
entity referred to by a JID, with respect to :xep:`30` semantics.
A :class:`Node` always has at least one identity (or it will return
as ````). It may have zero or more features beyond the
:xep:`30` features which are statically included.
To manage the identities and the features of a node, use the following
methods:
.. automethod:: register_feature
.. automethod:: unregister_feature
.. automethod:: register_identity
.. automethod:: set_identity_names
.. automethod:: unregister_identity
To access the declared features and identities, use:
.. automethod:: iter_features
.. automethod:: iter_identities
.. automethod:: as_info_xso
To access items, use:
.. automethod:: iter_items
Signals provide information about changes:
.. signal:: on_info_changed()
This signal emits when a feature or identity is registered or
unregistered.
As mentioned, bare :class:`Node` objects have no items; there are
subclasses of :class:`Node` which support items:
====================== ==================================================
:class:`StaticNode` Support for a list of :class:`.xso.Item` instances
:class:`.DiscoServer` Support for "mountpoints" for node subtrees
====================== ==================================================
"""
STATIC_FEATURES = frozenset({namespaces.xep0030_info})
on_info_changed = aioxmpp.callbacks.Signal()
def __init__(self):
super().__init__()
self._identities = {}
self._features = set()
def iter_identities(self, stanza=None):
"""
Return an iterator of tuples describing the identities of the node.
:param stanza: The IQ request stanza
:type stanza: :class:`~aioxmpp.IQ` or :data:`None`
:rtype: iterable of (:class:`str`, :class:`str`, :class:`str` or
:data:`None`, :class:`str` or :data:`None`) tuples
:return: :xep:`30` identities of this node
`stanza` can be the :class:`aioxmpp.IQ` stanza of the request. This can
be used to hide a node depending on who is asking. If the returned
iterable is empty, the :class:`~.DiscoServer` returns an
```` error.
`stanza` may be :data:`None` if the identities are queried without
a specific request context. In that case, implementors should assume
that the result is visible to everybody.
.. note::
Subclasses must allow :data:`None` for `stanza` and default it to
:data:`None`.
Return an iterator which yields tuples consisting of the category, the
type, the language code and the name of each identity declared in this
:class:`Node`.
Both the language code and the name may be :data:`None`, if no names or
a name without language code have been declared.
"""
for (category, type_), names in self._identities.items():
for lang, name in names.items():
yield category, type_, lang, name
if not names:
yield category, type_, None, None
def iter_features(self, stanza=None):
"""
Return an iterator which yields the features of the node.
:param stanza: The IQ request stanza
:type stanza: :class:`~aioxmpp.IQ`
:rtype: iterable of :class:`str`
:return: :xep:`30` features of this node
`stanza` is the :class:`aioxmpp.IQ` stanza of the request. This can be
used to filter the list according to who is asking (not recommended).
`stanza` may be :data:`None` if the features are queried without
a specific request context. In that case, implementors should assume
that the result is visible to everybody.
.. note::
Subclasses must allow :data:`None` for `stanza` and default it to
:data:`None`.
The features are returned as strings. The features demanded by
:xep:`30` are always returned.
"""
return itertools.chain(
iter(self.STATIC_FEATURES),
iter(self._features)
)
def iter_items(self, stanza=None):
"""
Return an iterator which yields the items of the node.
:param stanza: The IQ request stanza
:type stanza: :class:`~aioxmpp.IQ`
:rtype: iterable of :class:`~.disco.xso.Item`
:return: Items of the node
`stanza` is the :class:`aioxmpp.IQ` stanza of the request. This can be
used to localize the list to the language of the stanza or filter it
according to who is asking.
`stanza` may be :data:`None` if the items are queried without
a specific request context. In that case, implementors should assume
that the result is visible to everybody.
.. note::
Subclasses must allow :data:`None` for `stanza` and default it to
:data:`None`.
A bare :class:`Node` cannot hold any items and will thus return an
iterator which does not yield any element.
"""
return iter([])
def register_feature(self, var):
"""
Register a feature with the namespace variable `var`.
If the feature is already registered or part of the default :xep:`30`
features, a :class:`ValueError` is raised.
"""
if var in self._features or var in self.STATIC_FEATURES:
raise ValueError("feature already claimed: {!r}".format(var))
self._features.add(var)
self.on_info_changed()
def register_identity(self, category, type_, *, names={}):
"""
Register an identity with the given `category` and `type_`.
If there is already a registered identity with the same `category` and
`type_`, :class:`ValueError` is raised.
`names` may be a mapping which maps :class:`.structs.LanguageTag`
instances to strings. This mapping will be used to produce
```` declarations with the respective ``xml:lang`` and
``name`` attributes.
"""
key = category, type_
if key in self._identities:
raise ValueError("identity already claimed: {!r}".format(key))
self._identities[key] = names
self.on_info_changed()
def set_identity_names(self, category, type_, names={}):
"""
Update the names of an identity.
:param category: The category of the identity to update.
:type category: :class:`str`
:param type_: The type of the identity to update.
:type type_: :class:`str`
:param names: The new internationalised names to set for the identity.
:type names: :class:`~.abc.Mapping` from
:class:`.structs.LanguageTag` to :class:`str`
:raises ValueError: if no identity with the given category and type
is currently registered.
"""
key = category, type_
if key not in self._identities:
raise ValueError("identity not registered: {!r}".format(key))
self._identities[key] = names
self.on_info_changed()
def unregister_feature(self, var):
"""
Unregister a feature which has previously been registered using
:meth:`register_feature`.
If the feature has not been registered previously, :class:`KeyError` is
raised.
.. note::
The features which are mandatory per :xep:`30` are always registered
and cannot be unregistered. For the purpose of unregistration, they
behave as if they had never been registered; for the purpose of
registration, they behave as if they had been registered before.
"""
self._features.remove(var)
self.on_info_changed()
def unregister_identity(self, category, type_):
"""
Unregister an identity previously registered using
:meth:`register_identity`.
If no identity with the given `category` and `type_` has been
registered before, :class:`KeyError` is raised.
If the identity to remove is the last identity of the :class:`Node`,
:class:`ValueError` is raised; a node must always have at least one
identity.
"""
key = category, type_
if key not in self._identities:
raise KeyError(key)
if len(self._identities) == 1:
raise ValueError("cannot remove last identity")
del self._identities[key]
self.on_info_changed()
def as_info_xso(self, stanza=None):
"""
Construct a :class:`~.disco.xso.InfoQuery` response object for this
node.
:param stanza: The IQ request stanza
:type stanza: :class:`~aioxmpp.IQ`
:rtype: iterable of :class:`~.disco.xso.InfoQuery`
:return: The disco#info response for this node.
The resulting :class:`~.disco.xso.InfoQuery` carries the features and
identities as returned by :meth:`iter_features` and
:meth:`iter_identities`. The :attr:`~.disco.xso.InfoQuery.node`
attribute is at its default value and may need to be set by the caller
accordingly.
`stanza` is passed to :meth:`iter_features` and
:meth:`iter_identities`. See those methods for information on the
effects.
.. versionadded:: 0.9
"""
result = disco_xso.InfoQuery()
result.features.update(self.iter_features(stanza))
result.identities[:] = (
disco_xso.Identity(
category=category,
type_=type_,
lang=lang,
name=name,
)
for category, type_, lang, name in self.iter_identities(stanza)
)
return result
class StaticNode(Node):
"""
A :class:`StaticNode` is a :class:`Node` with a non-dynamic set of items.
.. attribute:: items
A list of :class:`.xso.Item` instances. These items will be returned
when the node is queried for it’s :xep:`30` items.
It is the responsibility of the user to ensure that the set of items is
valid. This includes avoiding duplicate items.
.. automethod:: clone
"""
def __init__(self):
super().__init__()
self.items = []
def iter_items(self, stanza=None):
return iter(self.items)
@classmethod
def clone(cls, other_node):
"""
Clone another :class:`Node` and return as :class:`StaticNode`.
:param other_node: The node which shall be cloned
:type other_node: :class:`Node`
:rtype: :class:`StaticNode`
:return: A static node which has the exact same features, identities
and items as `other_node`.
The features and identities are copied over into the resulting
:class:`StaticNode`. The items of `other_node` are not copied but
merely referenced, so changes to the item *objects* of `other_node`
will be reflected in the result.
.. versionadded:: 0.9
"""
result = cls()
result._features = {
feature for feature in other_node.iter_features()
if feature not in cls.STATIC_FEATURES
}
for category, type_, lang, name in other_node.iter_identities():
names = result._identities.setdefault(
(category, type_),
aioxmpp.structs.LanguageMap()
)
names[lang] = name
result.items = list(other_node.iter_items())
return result
class DiscoServer(service.Service, Node):
"""
Answer Service Discovery (:xep:`30`) requests sent to this client.
This service implements handlers for ``…disco#info`` and ``…disco#items``
IQ requests. It provides methods to configure the contents of these
responses.
.. seealso::
:class:`DiscoClient`
for a service which provides methods to query Service Discovery
information from other entities.
The :class:`DiscoServer` inherits from :class:`~.disco.Node` to manage the
identities and features of the client. The identities and features declared
in the service using the :class:`~.disco.Node` interface on the
:class:`DiscoServer` instance are returned when a query is received for the
JID with an empty or unset ``node`` attribute. For completeness, the
relevant methods are listed here. Refer to the :class:`~.disco.Node`
documentation for details.
.. autosummary::
.disco.Node.register_feature
.disco.Node.unregister_feature
.disco.Node.register_identity
.disco.Node.unregister_identity
.. note::
Upon construction, the :class:`DiscoServer` adds a default identity with
category ``"client"`` and type ``"bot"`` to the root
:class:`~.disco.Node`. This is to comply with :xep:`30`, which specifies
that at least one identity must always be returned. Otherwise, the
service would be forced to send a malformed response or reply with
````.
After having added another identity, that default identity can be
removed.
Other :class:`~.disco.Node` instances can be registered with the service
using the following methods:
.. automethod:: mount_node
.. automethod:: unmount_node
"""
on_info_result = aioxmpp.callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._node_mounts = {
None: self
}
self.register_identity(
"client", "bot",
names={
structs.LanguageTag.fromstr("en"): "aioxmpp default identity"
}
)
@aioxmpp.service.iq_handler(
aioxmpp.structs.IQType.GET,
disco_xso.InfoQuery)
async def handle_info_request(self, iq):
request = iq.payload
try:
node = self._node_mounts[request.node]
except KeyError:
raise errors.XMPPModifyError(
condition=errors.ErrorCondition.ITEM_NOT_FOUND
)
response = node.as_info_xso(iq)
response.node = request.node
if not response.identities:
raise errors.XMPPModifyError(
condition=errors.ErrorCondition.ITEM_NOT_FOUND,
)
return response
@aioxmpp.service.iq_handler(
aioxmpp.structs.IQType.GET,
disco_xso.ItemsQuery)
async def handle_items_request(self, iq):
request = iq.payload
try:
node = self._node_mounts[request.node]
except KeyError:
raise errors.XMPPModifyError(
condition=errors.ErrorCondition.ITEM_NOT_FOUND
)
response = disco_xso.ItemsQuery()
response.items.extend(node.iter_items(iq))
return response
def mount_node(self, mountpoint, node):
"""
Mount the :class:`Node` `node` to be returned when a peer requests
:xep:`30` information for the node `mountpoint`.
"""
self._node_mounts[mountpoint] = node
def unmount_node(self, mountpoint):
"""
Unmount the node mounted at `mountpoint`.
.. seealso::
:meth:`mount_node`
for a way for mounting :class:`~.disco.Node` instances.
"""
del self._node_mounts[mountpoint]
class DiscoClient(service.Service):
"""
Provide cache-backed Service Discovery (:xep:`30`) queries.
This service provides methods to query Service Discovery information from
other entities in the XMPP network. The results are cached transparently.
.. seealso::
:class:`.DiscoServer`
for a service which answers Service Discovery queries sent to the
client by other entities.
:class:`.EntityCapsService`
for a service which uses :xep:`115` to fill the cache of the
:class:`DiscoClient` with offline information.
Querying other entities’ service discovery information:
.. automethod:: query_info
.. automethod:: query_items
To prime the cache with information, the following methods can be used:
.. automethod:: set_info_cache
.. automethod:: set_info_future
To control the size of caches, the following properties are available:
.. autoattribute:: info_cache_size
:annotation: = 10000
.. autoattribute:: items_cache_size
:annotation: = 100
.. automethod:: flush_cache
Usage example, assuming that you have a :class:`.node.Client` `client`::
import aioxmpp.disco as disco
# load service into node
sd = client.summon(aioxmpp.DiscoClient)
# retrieve server information
server_info = yield from sd.query_info(
node.local_jid.replace(localpart=None, resource=None)
)
# retrieve resources
resources = yield from sd.query_items(
node.local_jid.bare()
)
"""
on_info_result = aioxmpp.callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._info_pending = aioxmpp.cache.LRUDict()
self._info_pending.maxsize = 10000
self._items_pending = aioxmpp.cache.LRUDict()
self._items_pending.maxsize = 100
self.client.on_stream_destroyed.connect(
self._clear_cache
)
@property
def info_cache_size(self):
"""
Maximum number of cache entries in the cache for :meth:`query_info`.
This is mostly a measure to prevent malicious peers from exhausting
memory by spamming :mod:`aioxmpp.entitycaps` capability hashes.
.. versionadded:: 0.9
"""
return self._info_pending.maxsize
@info_cache_size.setter
def info_cache_size(self, value):
self._info_pending.maxsize = value
@property
def items_cache_size(self):
"""
Maximum number of cache entries in the cache for :meth:`query_items`.
.. versionadded:: 0.9
"""
return self._items_pending.maxsize
@items_cache_size.setter
def items_cache_size(self, value):
self._items_pending.maxsize = value
def _clear_cache(self):
for fut in self._info_pending.values():
if not fut.done():
fut.cancel()
self._info_pending.clear()
for fut in self._items_pending.values():
if not fut.done():
fut.cancel()
self._items_pending.clear()
def _handle_info_received(self, jid, node, task):
try:
result = task.result()
except Exception:
return
self.on_info_result(jid, node, result)
def flush_cache(self):
"""
Clear the cache.
This clears the internal cache in a way which lets existing queries
continue, but the next query for each target will behave as if
`require_fresh` had been set to true.
"""
self._info_pending.clear()
self._items_pending.clear()
async def send_and_decode_info_query(self, jid, node):
request_iq = stanza.IQ(to=jid, type_=structs.IQType.GET)
request_iq.payload = disco_xso.InfoQuery(node=node)
response = await self.client.send(request_iq)
return response
async def query_info(self, jid, *,
node=None, require_fresh=False, timeout=None,
no_cache=False):
"""
Query the features and identities of the specified entity.
:param jid: The entity to query.
:type jid: :class:`aioxmpp.JID`
:param node: The node to query.
:type node: :class:`str` or :data:`None`
:param require_fresh: Boolean flag to discard previous caches.
:type require_fresh: :class:`bool`
:param timeout: Optional timeout for the response.
:type timeout: :class:`float`
:param no_cache: Boolean flag to forbid caching of the request.
:type no_cache: :class:`bool`
:rtype: :class:`.xso.InfoQuery`
:return: Service discovery information of the `node` at `jid`.
The requests are cached. This means that only one request is ever fired
for a given target (identified by the `jid` and the `node`). The
request is re-used for all subsequent requests to that identity.
If `require_fresh` is set to true, the above does not hold and a fresh
request is always created. The new request is the request which will be
used as alias for subsequent requests to the same identity.
The visible effects of this are twofold:
* Caching: Results of requests are implicitly cached
* Aliasing: Two concurrent requests will be aliased to one request to
save computing resources
Both can be turned off by using `require_fresh`. In general, you should
not need to use `require_fresh`, as all requests are implicitly
cancelled whenever the underlying session gets destroyed.
`no_cache` can be set to true to prevent future requests to be aliased
to this request, i.e. the request is not stored in the internal request
cache. This does not affect `require_fresh`, i.e. if a cached result is
available, it is used.
The `timeout` can be used to restrict the time to wait for a
response. If the timeout triggers, :class:`TimeoutError` is raised.
If :meth:`~.Client.send` raises an
exception, all queries which were running simultaneously for the same
target re-raise that exception. The result is not cached though. If a
new query is sent at a later point for the same target, a new query is
actually sent, independent of the value chosen for `require_fresh`.
.. versionchanged:: 0.9
The `no_cache` argument was added.
"""
key = jid, node
if not require_fresh:
try:
request = self._info_pending[key]
except KeyError:
pass
else:
try:
return await request
except asyncio.CancelledError:
pass
request = asyncio.ensure_future(
self.send_and_decode_info_query(jid, node)
)
request.add_done_callback(
functools.partial(
self._handle_info_received,
jid,
node
)
)
if not no_cache:
self._info_pending[key] = request
try:
if timeout is not None:
try:
result = await asyncio.wait_for(
request,
timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError()
else:
result = await request
except: # NOQA
if request.done():
try:
pending = self._info_pending[key]
except KeyError:
pass
else:
if pending is request:
del self._info_pending[key]
raise
return result
async def query_items(self, jid, *,
node=None, require_fresh=False, timeout=None):
"""
Query the items of the specified entity.
:param jid: The entity to query.
:type jid: :class:`aioxmpp.JID`
:param node: The node to query.
:type node: :class:`str` or :data:`None`
:param require_fresh: Boolean flag to discard previous caches.
:type require_fresh: :class:`bool`
:param timeout: Optional timeout for the response.
:type timeout: :class:`float`
:rtype: :class:`.xso.ItemsQuery`
:return: Service discovery items of the `node` at `jid`.
The arguments have the same semantics as with :meth:`query_info`, as
does the caching and error handling.
"""
key = jid, node
if not require_fresh:
try:
request = self._items_pending[key]
except KeyError:
pass
else:
try:
return await request
except asyncio.CancelledError:
pass
request_iq = stanza.IQ(to=jid, type_=structs.IQType.GET)
request_iq.payload = disco_xso.ItemsQuery(node=node)
request = asyncio.ensure_future(
self.client.send(request_iq)
)
self._items_pending[key] = request
try:
if timeout is not None:
try:
result = await asyncio.wait_for(
request,
timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError()
else:
result = await request
except: # NOQA
if request.done():
try:
pending = self._items_pending[key]
except KeyError:
pass
else:
if pending is request:
del self._items_pending[key]
raise
return result
def set_info_cache(self, jid, node, info):
"""
This is a wrapper around :meth:`set_info_future` which creates a future
and immediately assigns `info` as its result.
.. versionadded:: 0.5
"""
fut = asyncio.Future()
fut.set_result(info)
self.set_info_future(jid, node, fut)
def set_info_future(self, jid, node, fut):
"""
Override the cache entry (if one exists) for :meth:`query_info` of the
`jid` and `node` combination with the given :class:`asyncio.Future`
fut.
The future must receive a :class:`dict` compatible to the output of
:meth:`.xso.InfoQuery.to_dict`.
As usual, the cache can be bypassed and cleared by passing
`require_fresh` to :meth:`query_info`.
.. seealso::
Module :mod:`aioxmpp.entitycaps`
:xep:`0115` implementation which uses this method to prime the
cache with information derived from Entity Capability
announcements.
.. note::
If a future is set to exception state, it will still remain and make
all queries for that target fail with that exception, until a query
uses `require_fresh`.
.. versionadded:: 0.5
"""
self._info_pending[jid, node] = fut
class mount_as_node(service.Descriptor):
"""
Service descriptor which mounts the :class:`~.service.Service` as
:class:`.DiscoServer` node.
:param mountpoint: The mountpoint at which to mount the node.
:type mountpoint: :class:`str`
.. versionadded:: 0.8
When the service is instaniated, it is mounted as :class:`~.disco.Node` at
the given `mountpoint`; it must thus also inherit from
:class:`~.disco.Node` or implement a compatible interface.
.. autoattribute:: mountpoint
"""
def __init__(self, mountpoint):
super().__init__()
self._mountpoint = mountpoint
@property
def mountpoint(self):
"""
The mountpoint at which the node is mounted.
"""
return self._mountpoint
@property
def required_dependencies(self):
return [DiscoServer]
@contextlib.contextmanager
def init_cm(self, instance):
disco = instance.dependencies[DiscoServer]
disco.mount_node(self._mountpoint, instance)
try:
yield
finally:
disco.unmount_node(self._mountpoint)
@property
def value_type(self):
return type(None)
class RegisteredFeature:
"""
Manage registration of a feature with a :class:`DiscoServer`.
:param service: The service implementing the service discovery server.
:type service: :class:`DiscoServer`
:param feature: The feature to register.
:type feature: :class:`str`
.. note::
Normally, you would not create an instance of this object manually.
Use the :class:`register_feature` descriptor on your
:class:`aioxmpp.Service` which will provide a
:class:`RegisteredFeature` object::
class Foo(aioxmpp.Service):
_some_feature = aioxmpp.disco.register_feature(
"urn:of:the:feature"
)
# after __init__, self._some_feature is a RegisteredFeature
# instance.
@property
def some_feature_enabled(self):
# better do not expose the enabled boolean directly; this
# gives you the opportunity to do additional things when it
# is changed, such as disabling multiple features at once.
return self._some_feature.enabled
@some_feature_enabled.setter
def some_feature_enabled(self, value):
self._some_feature.enabled = value
.. versionadded:: 0.9
This object can be used as a context manager. Upon entering the context,
the feature is registered. When the context is left, the feature is
unregistered.
.. note::
The context-manager use does not nest sensibly. Thus, do not use
th context-manager feature on :class:`RegisteredFeature` instances
which are created by :class:`register_feature`, as
:class:`register_feature` uses the context manager to
register/unregister the feature on initialisation/shutdown.
Independently, it is possible to control the registration status of the
feature using :attr:`enabled`.
.. autoattribute:: enabled
.. autoattribute:: feature
"""
def __init__(self, service, feature):
self.__service = service
self.__feature = feature
self.__enabled = False
@property
def enabled(self):
"""
Boolean indicating whether the feature is registered by this object
or not.
When this attribute is changed to :data:`True`, the feature is
registered. When the attribute is changed to :data:`False`, the feature
is unregistered.
"""
return self.__enabled
@enabled.setter
def enabled(self, value):
value = bool(value)
if value == self.__enabled:
return
if value:
self.__service.register_feature(self.__feature)
else:
self.__service.unregister_feature(self.__feature)
self.__enabled = value
@property
def feature(self):
"""
The feature this object is controlling (read-only).
"""
return self.__feature
def __enter__(self):
self.enabled = True
return self
def __exit__(self, exc_type, exc_value, tb):
self.enabled = False
class register_feature(service.Descriptor):
"""
Service descriptor which registers a service discovery feature.
:param feature: The feature to register.
:type feature: :class:`str`
.. versionadded:: 0.8
When the service is instaniated, the `feature` is registered at the
:class:`~.DiscoServer`.
On instances, the attribute which is described with this is a
:class:`RegisteredFeature` instance.
.. versionchanged:: 0.9
:class:`RegisteredFeature` was added; before, the attribute reads as
:data:`None`.
"""
def __init__(self, feature):
super().__init__()
self._feature = feature
@property
def feature(self):
"""
The feature which is registered.
"""
return self._feature
@property
def required_dependencies(self):
return [DiscoServer]
def init_cm(self, instance):
disco = instance.dependencies[DiscoServer]
return RegisteredFeature(disco, self._feature)
@property
def value_type(self):
return RegisteredFeature
aioxmpp/disco/xso.py 0000664 0000000 0000000 00000020467 14160146213 0015026 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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
`_.
.. attribute:: type_
The type of the identity. The value is not validated against the values
in the `registry
`_.
.. 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
aioxmpp/dispatcher.py 0000664 0000000 0000000 00000036523 14160146213 0015242 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
aioxmpp/e2etest/ 0000775 0000000 0000000 00000000000 14160146213 0014104 5 ustar 00root root 0000000 0000000 aioxmpp/e2etest/__init__.py 0000664 0000000 0000000 00000035636 14160146213 0016232 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
`.
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)()
aioxmpp/e2etest/__main__.py 0000664 0000000 0000000 00000002071 14160146213 0016176 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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:],
)
aioxmpp/e2etest/provision.py 0000664 0000000 0000000 00000067205 14160146213 0016520 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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
aioxmpp/e2etest/utils.py 0000664 0000000 0000000 00000002662 14160146213 0015624 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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
aioxmpp/entitycaps/ 0000775 0000000 0000000 00000000000 14160146213 0014714 5 ustar 00root root 0000000 0000000 aioxmpp/entitycaps/__init__.py 0000664 0000000 0000000 00000003516 14160146213 0017032 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
: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
aioxmpp/entitycaps/caps115.py 0000664 0000000 0000000 00000011310 14160146213 0016437 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
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"),
)
)
aioxmpp/entitycaps/caps390.py 0000664 0000000 0000000 00000012471 14160146213 0016455 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: caps390.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
# .
#
########################################################################
import base64
import pathlib
import collections
import urllib.parse
import aioxmpp.hashes
from .common import AbstractKey
from . import xso as caps_xso
def _process_features(features):
"""
Generate the `Features String` from an iterable of features.
:param features: The features to generate the features string from.
:type features: :class:`~collections.abc.Iterable` of :class:`str`
:return: The `Features String`
:rtype: :class:`bytes`
Generate the `Features String` from the given `features` as specified in
:xep:`390`.
"""
parts = [
feature.encode("utf-8")+b"\x1f"
for feature in features
]
parts.sort()
return b"".join(parts)+b"\x1c"
def _process_identity(identity):
category = (identity.category or "").encode("utf-8")+b"\x1f"
type_ = (identity.type_ or "").encode("utf-8")+b"\x1f"
lang = str(identity.lang or "").encode("utf-8")+b"\x1f"
name = (identity.name or "").encode("utf-8")+b"\x1f"
return b"".join([category, type_, lang, name]) + b"\x1e"
def _process_identities(identities):
"""
Generate the `Identities String` from an iterable of identities.
:param identities: The identities to generate the features string from.
:type identities: :class:`~collections.abc.Iterable` of
:class:`~.disco.xso.Identity`
:return: The `Identities String`
:rtype: :class:`bytes`
Generate the `Identities String` from the given `identities` as specified
in :xep:`390`.
"""
parts = [
_process_identity(identity)
for identity in identities
]
parts.sort()
return b"".join(parts)+b"\x1c"
def _process_field(field):
parts = [
(value or "").encode("utf-8") + b"\x1f"
for value in field.values
]
parts.insert(0, field.var.encode("utf-8")+b"\x1f")
return b"".join(parts)+b"\x1e"
def _process_form(form):
parts = [
_process_field(form)
for form in form.fields
]
parts.sort()
return b"".join(parts)+b"\x1d"
def _process_extensions(exts):
"""
Generate the `Extensions String` from an iterable of data forms.
:param exts: The data forms to generate the extensions string from.
:type exts: :class:`~collections.abc.Iterable` of
:class:`~.forms.xso.Data`
:return: The `Extensions String`
:rtype: :class:`bytes`
Generate the `Extensions String` from the given `exts` as specified
in :xep:`390`.
"""
parts = [
_process_form(form)
for form in exts
]
parts.sort()
return b"".join(parts)+b"\x1c"
def _get_hash_input(info):
return b"".join([
_process_features(info.features),
_process_identities(info.identities),
_process_extensions(info.exts)
])
def _calculate_hash(algo, hash_input):
impl = aioxmpp.hashes.hash_from_algo(algo)
impl.update(hash_input)
return impl.digest()
Key = collections.namedtuple("Key", ["algo", "digest"])
class Key(Key, AbstractKey):
@property
def node(self):
return "urn:xmpp:caps#{}.{}".format(
self.algo,
base64.b64encode(self.digest).decode("ascii")
)
@property
def path(self):
encoded = base64.b32encode(
self.digest
).decode("ascii").rstrip("=").lower()
return (pathlib.Path("caps2") /
urllib.parse.quote(self.algo, safe="") /
encoded[:2] /
encoded[2:4] /
"{}.xml".format(encoded[4:]))
def verify(self, info):
if not isinstance(info, bytes):
info = _get_hash_input(info)
digest = _calculate_hash(self.algo, info)
return digest == self.digest
class Implementation:
def __init__(self, algorithms, **kwargs):
super().__init__(**kwargs)
self.__algorithms = algorithms
def extract_keys(self, presence):
if presence.xep0390_caps is None:
return ()
return (
Key(algo, digest)
for algo, digest in presence.xep0390_caps.digests.items()
if aioxmpp.hashes.is_algo_supported(algo)
)
def put_keys(self, keys, presence):
presence.xep0390_caps = caps_xso.Caps390()
presence.xep0390_caps.digests.update({
key.algo: key.digest
for key in keys
})
def calculate_keys(self, query_response):
input = _get_hash_input(query_response)
for algo in self.__algorithms:
yield Key(algo, _calculate_hash(algo, input))
aioxmpp/entitycaps/common.py 0000664 0000000 0000000 00000007500 14160146213 0016560 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: common.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
# .
#
########################################################################
import abc
class AbstractKey(metaclass=abc.ABCMeta):
@abc.abstractproperty
def path(self):
"""
Return the file system path relative to the root of a file-system based
caps database for this key.
The path includes all information of the key. Components of the path do
not exceed 255 codepoints and use only ASCII codepoints.
If it is not possible to create such a path, :class:`ValueError` is
raised.
"""
@abc.abstractmethod
def verify(self, query_response):
"""
Verify whether the cache key matches a piece of service discovery
information.
:param query_response: The full :xep:`30` disco#info query response.
:type query_response: :class:`~.disco.xso.InfoQuery`
:rtype: :class:`bool`
:return: true if the key matches and false otherwise.
"""
class AbstractImplementation(metaclass=abc.ABCMeta):
@abc.abstractmethod
def extract_keys(self, presence):
"""
Extract cache keys from a presence stanza.
:param presence: Presence stanza to extract cache keys from.
:type presence: :class:`aioxmpp.Presence`
:rtype: :class:`~collections.abc.Iterable` of :class:`AbstractKey`
:return: The cache keys from the presence stanza.
The resulting iterable may be empty if the presence stanza does not
carry any capabilities information with it.
The resulting iterable cannot be iterated over multiple times.
"""
@abc.abstractmethod
def put_keys(self, keys, presence):
"""
Insert cache keys into a presence stanza.
:param keys: An iterable of cache keys to insert.
:type keys: :class:`~collections.abc.Iterable` of :class:`AbstractKey`
objects
:param presence: The presence stanza into which the cache keys shall be
injected.
:type presence: :class:`aioxmpp.Presence`
The presence stanza is modified in-place.
"""
@abc.abstractmethod
def calculate_keys(self, query_response):
"""
Calculate the cache keys for a disco#info response.
:param query_response: The full :xep:`30` disco#info query response.
:type query_response: :class:`~.disco.xso.InfoQuery`
:rtype: :class:`~collections.abc.Iterable` of :class:`AbstractKey`
:return: An iterable of the cache keys for the disco#info response.
..
:param identities: The identities of the disco#info response.
:type identities: :class:`~collections.abc.Iterable` of
:class:`~.disco.xso.Identity`
:param features: The features of the disco#info response.
:type features: :class:`~collections.abc.Iterable` of
:class:`str`
:param features: The extensions of the disco#info response.
:type features: :class:`~collections.abc.Iterable` of
:class:`~.forms.xso.Data`
"""
aioxmpp/entitycaps/service.py 0000664 0000000 0000000 00000037772 14160146213 0016746 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import collections
import copy
import functools
import logging
import os
import tempfile
import aioxmpp.callbacks
import aioxmpp.disco as disco
import aioxmpp.service
import aioxmpp.utils
import aioxmpp.xml
import aioxmpp.xso
from aioxmpp.utils import namespaces
from . import caps115, caps390
logger = logging.getLogger("aioxmpp.entitycaps")
class Cache:
"""
This provides a two-level cache for entity capabilities information. The
idea is to have a trusted database, e.g. installed system-wide or shipped
with :mod:`aioxmpp` and in addition a user-level database which is
automatically filled with hashes which have been found by the
:class:`Service`.
The trusted database is taken as read-only and overrides the user-collected
database. When a hash is in both databases, it is removed from the
user-collected database (to save space).
In addition to serving the databases, it provides deduplication for queries
by holding a cache of futures looking up the same hash.
Database management (user API):
.. automethod:: set_system_db_path
.. automethod:: set_user_db_path
Queries (API intended for :class:`Service`):
.. automethod:: create_query_future
.. automethod:: lookup_in_database
.. automethod:: lookup
"""
def __init__(self):
self._lookup_cache = {}
self._memory_overlay = {}
self._system_db_path = None
self._user_db_path = None
def _erase_future(self, key, fut):
try:
existing = self._lookup_cache[key]
except KeyError:
pass
else:
if existing is fut:
del self._lookup_cache[key]
def set_system_db_path(self, path):
self._system_db_path = path
def set_user_db_path(self, path):
self._user_db_path = path
def lookup_in_database(self, key):
try:
result = self._memory_overlay[key]
except KeyError:
pass
else:
logger.debug("memory cache hit: %s", key)
return result
key_path = key.path
if self._system_db_path is not None:
try:
f = (
self._system_db_path / key_path
).open("rb")
except OSError:
pass
else:
logger.debug("system db hit: %s", key)
with f:
return aioxmpp.xml.read_single_xso(f, disco.xso.InfoQuery)
if self._user_db_path is not None:
try:
f = (
self._user_db_path / key_path
).open("rb")
except OSError:
pass
else:
logger.debug("user db hit: %s", key)
with f:
return aioxmpp.xml.read_single_xso(f, disco.xso.InfoQuery)
raise KeyError(key)
async def lookup(self, key):
"""
Look up the given `node` URL using the given `hash_` first in the
database and then by waiting on the futures created with
:meth:`create_query_future` for that node URL and hash.
If the hash is not in the database, :meth:`lookup` iterates as long as
there are pending futures for the given `hash_` and `node`. If there
are no pending futures, :class:`KeyError` is raised. If a future raises
a :class:`ValueError`, it is ignored. If the future returns a value, it
is used as the result.
"""
try:
result = self.lookup_in_database(key)
except KeyError:
pass
else:
return result
while True:
fut = self._lookup_cache[key]
try:
result = await fut
except ValueError:
continue
else:
return result
def create_query_future(self, key):
"""
Create and return a :class:`asyncio.Future` for the given `hash_`
function and `node` URL. The future is referenced internally and used
by any calls to :meth:`lookup` which are made while the future is
pending. The future is removed from the internal storage automatically
when a result or exception is set for it.
This allows for deduplication of queries for the same hash.
"""
fut = asyncio.Future()
fut.add_done_callback(
functools.partial(self._erase_future, key)
)
self._lookup_cache[key] = fut
return fut
def add_cache_entry(self, key, entry):
"""
Add the given `entry` (which must be a :class:`~.disco.xso.InfoQuery`
instance) to the user-level database keyed with the hash function type
`hash_` and the `node` URL. The `entry` is **not** validated to
actually map to `node` with the given `hash_` function, it is expected
that the caller performs the validation.
"""
copied_entry = copy.copy(entry)
self._memory_overlay[key] = copied_entry
if self._user_db_path is not None:
asyncio.ensure_future(asyncio.get_event_loop().run_in_executor(
None,
writeback,
self._user_db_path / key.path,
entry.captured_events))
class EntityCapsService(aioxmpp.service.Service):
"""
Make use and provide service discovery information in presence broadcasts.
This service implements :xep:`0115` and :xep:`0390`, transparently.
Besides loading the service, no interaction is required to get some of
the benefits of :xep:`0115` and :xep:`0390`.
Two additional things need to be done by users to get full support and
performance:
1. To make sure that peers are always up-to-date with the current
capabilities, it is required that users listen on the
:meth:`on_ver_changed` signal and re-emit their current presence when it
fires.
.. note::
Keeping peers up-to-date is a MUST in :xep:`390`.
The service takes care of attaching capabilities information on the
outgoing stanza, using a stanza filter.
.. warning::
:meth:`on_ver_changed` may be emitted at a considerable rate when
services are loaded or certain features (such as PEP-based services)
are configured. It is up to the application to limit the rate at
which presences are sent for the sole purpose of updating peers with
new capability information.
2. Users should use a process-wide :class:`Cache` instance and assign it to
the :attr:`cache` of each :class:`.entitycaps.Service` they use. This
improves performance by sharing (verified) hashes among :class:`Service`
instances.
In addition, the hashes should be saved and restored on shutdown/start
of the process. See the :class:`Cache` for details.
.. signal:: on_ver_changed
The signal emits whenever the Capability Hashset of the local client
changes. This happens when the set of features or identities announced
in the :class:`.DiscoServer` changes.
.. autoattribute:: cache
.. autoattribute:: xep115_support
.. autoattribute:: xep390_support
.. versionchanged:: 0.8
This class was formerly known as :class:`aioxmpp.entitycaps.Service`. It
is still available under that name, but the alias will be removed in
1.0.
.. versionchanged:: 0.9
Support for :xep:`390` was added.
"""
ORDER_AFTER = {
disco.DiscoClient,
disco.DiscoServer,
}
NODE = "http://aioxmpp.zombofant.net/"
on_ver_changed = aioxmpp.callbacks.Signal()
def __init__(self, node, **kwargs):
super().__init__(node, **kwargs)
self.__current_keys = {}
self._cache = Cache()
self.disco_server = self.dependencies[disco.DiscoServer]
self.disco_client = self.dependencies[disco.DiscoClient]
self.__115 = caps115.Implementation(self.NODE)
self.__390 = caps390.Implementation(
aioxmpp.hashes.default_hash_algorithms
)
self.__active_hashsets = []
self.__key_users = collections.Counter()
@property
def xep115_support(self):
"""
Boolean to control whether :xep:`115` support is enabled or not.
Defaults to :data:`True`.
If set to false, inbound :xep:`115` capabilities will not be processed
and no :xep:`115` capabilities will be emitted.
.. note::
At some point, this will default to :data:`False` to save
bandwidth. The exact release depends on the adoption of :xep:`390`
and will be announced in time. If you depend on :xep:`115` support,
set this boolean to :data:`True`.
The attribute itself will not be removed until :xep:`115` support
is removed from :mod:`aioxmpp` entirely, which is unlikely to
happen any time soon.
.. versionadded:: 0.9
"""
return self._xep115_feature.enabled
@xep115_support.setter
def xep115_support(self, value):
self._xep115_feature.enabled = value
@property
def xep390_support(self):
"""
Boolean to control whether :xep:`390` support is enabled or not.
Defaults to :data:`True`.
If set to false, inbound :xep:`390` Capability Hash Sets will not be
processed and no Capability Hash Sets or Capability Nodes will be
generated.
The hash algorithms used for generating Capability Hash Sets are those
from :data:`aioxmpp.hashes.default_hash_algorithms`.
"""
return self._xep390_feature.enabled
@xep390_support.setter
def xep390_support(self, value):
self._xep390_feature.enabled = value
@property
def cache(self):
"""
The :class:`Cache` instance used for this :class:`Service`. Deleting
this attribute will automatically create a new :class:`Cache` instance.
The attribute can be used to share a single :class:`Cache` among
multiple :class:`Service` instances.
"""
return self._cache
@cache.setter
def cache(self, v):
self._cache = v
@cache.deleter
def cache(self):
self._cache = Cache()
@aioxmpp.service.depsignal(
disco.DiscoServer,
"on_info_changed")
def _info_changed(self):
self.logger.debug("info changed, scheduling re-calculation of version")
asyncio.get_event_loop().call_soon(
self.update_hash
)
async def _shutdown(self):
for group in self.__current_keys.values():
for key in group:
self.disco_server.unmount_node(key.node)
async def query_and_cache(self, jid, key, fut):
data = await self.disco_client.query_info(
jid,
node=key.node,
require_fresh=True,
no_cache=True, # the caps node is never queried by apps
)
try:
if key.verify(data):
self.cache.add_cache_entry(key, data)
fut.set_result(data)
else:
raise ValueError("hash mismatch")
except ValueError as exc:
fut.set_exception(exc)
return data
async def lookup_info(self, jid, keys):
for key in keys:
try:
info = await self.cache.lookup(key)
except KeyError:
continue
self.logger.debug("found %s in cache", key)
return info
first_key = keys[0]
self.logger.debug("using key %s to query peer", first_key)
fut = self.cache.create_query_future(first_key)
info = await self.query_and_cache(
jid, first_key, fut
)
self.logger.debug("%s maps to %r", key, info)
return info
@aioxmpp.service.outbound_presence_filter
def handle_outbound_presence(self, presence):
if (presence.type_ == aioxmpp.structs.PresenceType.AVAILABLE
and self.__active_hashsets):
current_hashset = self.__active_hashsets[-1]
try:
keys = current_hashset[self.__115]
except KeyError:
pass
else:
self.__115.put_keys(keys, presence)
try:
keys = current_hashset[self.__390]
except KeyError:
pass
else:
self.__390.put_keys(keys, presence)
return presence
@aioxmpp.service.inbound_presence_filter
def handle_inbound_presence(self, presence):
keys = []
if self.xep390_support:
keys.extend(self.__390.extract_keys(presence))
if self.xep115_support:
keys.extend(self.__115.extract_keys(presence))
if keys:
lookup_task = aioxmpp.utils.LazyTask(
self.lookup_info,
presence.from_,
keys,
)
self.disco_client.set_info_future(
presence.from_,
None,
lookup_task
)
return presence
def _push_hashset(self, node, hashset):
if self.__active_hashsets and hashset == self.__active_hashsets[-1]:
return False
for group in hashset.values():
for key in group:
if not self.__key_users[key.node]:
self.disco_server.mount_node(key.node, node)
self.__key_users[key.node] += 1
self.__active_hashsets.append(hashset)
for expired in self.__active_hashsets[:-3]:
for group in expired.values():
for key in group:
self.__key_users[key.node] -= 1
if not self.__key_users[key.node]:
self.disco_server.unmount_node(key.node)
del self.__key_users[key.node]
del self.__active_hashsets[:-3]
return True
def update_hash(self):
node = disco.StaticNode.clone(self.disco_server)
info = node.as_info_xso()
new_hashset = {}
if self.xep115_support:
new_hashset[self.__115] = set(self.__115.calculate_keys(info))
if self.xep390_support:
new_hashset[self.__390] = set(self.__390.calculate_keys(info))
self.logger.debug("new hashset=%r", new_hashset)
if self._push_hashset(node, new_hashset):
self.on_ver_changed()
# declare those at the bottom so that on_ver_changed gets emitted when the
# service is instantiated
_xep115_feature = disco.register_feature(namespaces.xep0115_caps)
_xep390_feature = disco.register_feature(namespaces.xep0390_caps)
def writeback(path, captured_events):
aioxmpp.utils.mkdir_exist_ok(path.parent)
with tempfile.NamedTemporaryFile(dir=str(path.parent),
delete=False) as tmpf:
try:
generator = aioxmpp.xml.XMPPXMLGenerator(
tmpf,
short_empty_elements=True)
generator.startDocument()
aioxmpp.xso.events_to_sax(captured_events, generator)
generator.endDocument()
except: # NOQA
os.unlink(tmpf.name)
raise
os.replace(tmpf.name, str(path))
aioxmpp/entitycaps/xso.py 0000664 0000000 0000000 00000004302 14160146213 0016076 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp.hashes
import aioxmpp.stanza as stanza
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0115_caps = "http://jabber.org/protocol/caps"
namespaces.xep0390_caps = "urn:xmpp:caps"
class Caps115(xso.XSO):
"""
An entity capabilities extension for :class:`~.Presence`.
.. attribute:: node
The indicated node, for use with the corresponding info query.
.. attribute:: hash_
The hash algorithm used. This is :data:`None` if the legacy format is
used.
.. attribute:: ver
The version (in the legacy format) or the calculated hash.
.. attribute:: ext
Only there for backwards compatibility. Not used anymore.
"""
TAG = (namespaces.xep0115_caps, "c")
node = xso.Attr("node")
hash_ = xso.Attr(
"hash",
validator=xso.Nmtoken(),
validate=xso.ValidateMode.FROM_CODE,
default=None # to check for legacy
)
ver = xso.Attr("ver")
ext = xso.Attr("ext", default=None)
def __init__(self, node, ver, hash_):
super().__init__()
self.node = node
self.ver = ver
self.hash_ = hash_
class Caps390(aioxmpp.hashes.HashesParent, xso.XSO):
TAG = namespaces.xep0390_caps, "c"
stanza.Presence.xep0115_caps = xso.Child([Caps115])
stanza.Presence.xep0390_caps = xso.Child([Caps390])
aioxmpp/errors.py 0000664 0000000 0000000 00000050114 14160146213 0014420 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: errors.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.errors` --- Exception classes
############################################
Exception classes mapping to XMPP stream errors
===============================================
.. autoclass:: StreamError
.. autoclass:: StreamErrorCondition
Exception classes mapping to XMPP stanza errors
===============================================
.. autoclass:: StanzaError
.. autoclass:: XMPPError
.. currentmodule:: aioxmpp
.. autoclass:: ErrorCondition
.. autoclass:: XMPPAuthError
.. autoclass:: XMPPModifyError
.. autoclass:: XMPPCancelError
.. autoclass:: XMPPWaitError
.. autoclass:: XMPPContinueError
.. currentmodule:: aioxmpp.errors
.. autoclass:: ErroneousStanza
Stream negotiation exceptions
=============================
.. autoclass:: StreamNegotiationFailure
.. autoclass:: SecurityNegotiationFailure
.. autoclass:: SASLUnavailable
.. autoclass:: TLSFailure
.. autoclass:: TLSUnavailable
I18N exceptions
===============
.. autoclass:: UserError
.. autoclass:: UserValueError
Other exceptions
================
.. autoclass:: MultiOSError
.. autoclass:: GatherError
"""
import enum
import gettext
import warnings
from . import xso, i18n, structs
from .utils import namespaces
def format_error_text(
condition,
text=None,
application_defined_condition=None):
error_tag = xso.tag_to_str(condition.value)
if application_defined_condition is not None:
error_tag += "/{}".format(
xso.tag_to_str(application_defined_condition.TAG)
)
if text:
error_tag += " ({!r})".format(text)
return error_tag
class ErrorCondition(structs.CompatibilityMixin, xso.XSOEnumMixin, enum.Enum):
"""
Enumeration to represent a :rfc:`6120` stanza error condition. Please
see :rfc:`6120`, section 8.3.3, for the semantics of the individual
conditions.
.. versionadded:: 0.10
.. attribute:: BAD_REQUEST
:annotation: = namespaces.stanzas, "bad-request"
.. attribute:: CONFLICT
:annotation: = namespaces.stanzas, "conflict"
.. attribute:: FEATURE_NOT_IMPLEMENTED
:annotation: = namespaces.stanzas, "feature-not-implemented"
.. attribute:: FORBIDDEN
:annotation: = namespaces.stanzas, "forbidden"
.. attribute:: GONE
:annotation: = namespaces.stanzas, "gone"
.. attribute:: xso_class
.. attribute:: new_address
The text content of the ```` element represtenting the
URI at which the entity can now be found.
May be :data:`None` if there is no such URI.
.. attribute:: INTERNAL_SERVER_ERROR
:annotation: = namespaces.stanzas, "internal-server-error"
.. attribute:: ITEM_NOT_FOUND
:annotation: = namespaces.stanzas, "item-not-found"
.. attribute:: JID_MALFORMED
:annotation: = namespaces.stanzas, "jid-malformed"
.. attribute:: NOT_ACCEPTABLE
:annotation: = namespaces.stanzas, "not-acceptable"
.. attribute:: NOT_ALLOWED
:annotation: = namespaces.stanzas, "not-allowed"
.. attribute:: NOT_AUTHORIZED
:annotation: = namespaces.stanzas, "not-authorized"
.. attribute:: POLICY_VIOLATION
:annotation: = namespaces.stanzas, "policy-violation"
.. attribute:: RECIPIENT_UNAVAILABLE
:annotation: = namespaces.stanzas, "recipient-unavailable"
.. attribute:: REDIRECT
:annotation: = namespaces.stanzas, "redirect"
.. attribute:: xso_class
.. attribute:: new_address
The text content of the ```` element represtenting
the URI at which the entity can currently be found.
May be :data:`None` if there is no such URI.
.. attribute:: REGISTRATION_REQUIRED
:annotation: = namespaces.stanzas, "registration-required"
.. attribute:: REMOTE_SERVER_NOT_FOUND
:annotation: = namespaces.stanzas, "remote-server-not-found"
.. attribute:: REMOTE_SERVER_TIMEOUT
:annotation: = namespaces.stanzas, "remote-server-timeout"
.. attribute:: RESOURCE_CONSTRAINT
:annotation: = namespaces.stanzas, "resource-constraint"
.. attribute:: SERVICE_UNAVAILABLE
:annotation: = namespaces.stanzas, "service-unavailable"
.. attribute:: SUBSCRIPTION_REQUIRED
:annotation: = namespaces.stanzas, "subscription-required"
.. attribute:: UNDEFINED_CONDITION
:annotation: = namespaces.stanzas, "undefined-condition"
.. attribute:: UNEXPECTED_REQUEST
:annotation: = namespaces.stanzas, "unexpected-request"
"""
BAD_REQUEST = (namespaces.stanzas, "bad-request")
CONFLICT = (namespaces.stanzas, "conflict")
FEATURE_NOT_IMPLEMENTED = (namespaces.stanzas, "feature-not-implemented")
FORBIDDEN = (namespaces.stanzas, "forbidden")
GONE = (namespaces.stanzas, "gone")
INTERNAL_SERVER_ERROR = (namespaces.stanzas, "internal-server-error")
ITEM_NOT_FOUND = (namespaces.stanzas, "item-not-found")
JID_MALFORMED = (namespaces.stanzas, "jid-malformed")
NOT_ACCEPTABLE = (namespaces.stanzas, "not-acceptable")
NOT_ALLOWED = (namespaces.stanzas, "not-allowed")
NOT_AUTHORIZED = (namespaces.stanzas, "not-authorized")
POLICY_VIOLATION = (namespaces.stanzas, "policy-violation")
RECIPIENT_UNAVAILABLE = (namespaces.stanzas, "recipient-unavailable")
REDIRECT = (namespaces.stanzas, "redirect")
REGISTRATION_REQUIRED = (namespaces.stanzas, "registration-required")
REMOTE_SERVER_NOT_FOUND = (namespaces.stanzas, "remote-server-not-found")
REMOTE_SERVER_TIMEOUT = (namespaces.stanzas, "remote-server-timeout")
RESOURCE_CONSTRAINT = (namespaces.stanzas, "resource-constraint")
SERVICE_UNAVAILABLE = (namespaces.stanzas, "service-unavailable")
SUBSCRIPTION_REQUIRED = (namespaces.stanzas, "subscription-required")
UNDEFINED_CONDITION = (namespaces.stanzas, "undefined-condition")
UNEXPECTED_REQUEST = (namespaces.stanzas, "unexpected-request")
ErrorCondition.GONE.xso_class.new_address = xso.Text()
ErrorCondition.REDIRECT.xso_class.new_address = xso.Text()
class StreamErrorCondition(structs.CompatibilityMixin,
xso.XSOEnumMixin,
enum.Enum):
"""
Enumeration to represent a :rfc:`6120` stream error condition. Please
see :rfc:`6120`, section 4.9.3, for the semantics of the individual
conditions.
.. versionadded:: 0.10
.. attribute:: BAD_FORMAT
:annotation: = (namespaces.streams, "bad-format")
.. attribute:: BAD_NAMESPACE_PREFIX
:annotation: = (namespaces.streams, "bad-namespace-prefix")
.. attribute:: CONFLICT
:annotation: = (namespaces.streams, "conflict")
.. attribute:: CONNECTION_TIMEOUT
:annotation: = (namespaces.streams, "connection-timeout")
.. attribute:: HOST_GONE
:annotation: = (namespaces.streams, "host-gone")
.. attribute:: HOST_UNKNOWN
:annotation: = (namespaces.streams, "host-unknown")
.. attribute:: IMPROPER_ADDRESSING
:annotation: = (namespaces.streams, "improper-addressing")
.. attribute:: INTERNAL_SERVER_ERROR
:annotation: = (namespaces.streams, "internal-server-error")
.. attribute:: INVALID_FROM
:annotation: = (namespaces.streams, "invalid-from")
.. attribute:: INVALID_NAMESPACE
:annotation: = (namespaces.streams, "invalid-namespace")
.. attribute:: INVALID_XML
:annotation: = (namespaces.streams, "invalid-xml")
.. attribute:: NOT_AUTHORIZED
:annotation: = (namespaces.streams, "not-authorized")
.. attribute:: NOT_WELL_FORMED
:annotation: = (namespaces.streams, "not-well-formed")
.. attribute:: POLICY_VIOLATION
:annotation: = (namespaces.streams, "policy-violation")
.. attribute:: REMOTE_CONNECTION_FAILED
:annotation: = (namespaces.streams, "remote-connection-failed")
.. attribute:: RESET
:annotation: = (namespaces.streams, "reset")
.. attribute:: RESOURCE_CONSTRAINT
:annotation: = (namespaces.streams, "resource-constraint")
.. attribute:: RESTRICTED_XML
:annotation: = (namespaces.streams, "restricted-xml")
.. attribute:: SEE_OTHER_HOST
:annotation: = (namespaces.streams, "see-other-host")
.. attribute:: SYSTEM_SHUTDOWN
:annotation: = (namespaces.streams, "system-shutdown")
.. attribute:: UNDEFINED_CONDITION
:annotation: = (namespaces.streams, "undefined-condition")
.. attribute:: UNSUPPORTED_ENCODING
:annotation: = (namespaces.streams, "unsupported-encoding")
.. attribute:: UNSUPPORTED_FEATURE
:annotation: = (namespaces.streams, "unsupported-feature")
.. attribute:: UNSUPPORTED_STANZA_TYPE
:annotation: = (namespaces.streams, "unsupported-stanza-type")
.. attribute:: UNSUPPORTED_VERSION
:annotation: = (namespaces.streams, "unsupported-version")
"""
BAD_FORMAT = (namespaces.streams, "bad-format")
BAD_NAMESPACE_PREFIX = (namespaces.streams, "bad-namespace-prefix")
CONFLICT = (namespaces.streams, "conflict")
CONNECTION_TIMEOUT = (namespaces.streams, "connection-timeout")
HOST_GONE = (namespaces.streams, "host-gone")
HOST_UNKNOWN = (namespaces.streams, "host-unknown")
IMPROPER_ADDRESSING = (namespaces.streams, "improper-addressing")
INTERNAL_SERVER_ERROR = (namespaces.streams, "internal-server-error")
INVALID_FROM = (namespaces.streams, "invalid-from")
INVALID_NAMESPACE = (namespaces.streams, "invalid-namespace")
INVALID_XML = (namespaces.streams, "invalid-xml")
NOT_AUTHORIZED = (namespaces.streams, "not-authorized")
NOT_WELL_FORMED = (namespaces.streams, "not-well-formed")
POLICY_VIOLATION = (namespaces.streams, "policy-violation")
REMOTE_CONNECTION_FAILED = (namespaces.streams, "remote-connection-failed")
RESET = (namespaces.streams, "reset")
RESOURCE_CONSTRAINT = (namespaces.streams, "resource-constraint")
RESTRICTED_XML = (namespaces.streams, "restricted-xml")
SEE_OTHER_HOST = (namespaces.streams, "see-other-host")
SYSTEM_SHUTDOWN = (namespaces.streams, "system-shutdown")
UNDEFINED_CONDITION = (namespaces.streams, "undefined-condition")
UNSUPPORTED_ENCODING = (namespaces.streams, "unsupported-encoding")
UNSUPPORTED_FEATURE = (namespaces.streams, "unsupported-feature")
UNSUPPORTED_STANZA_TYPE = (namespaces.streams, "unsupported-stanza-type")
UNSUPPORTED_VERSION = (namespaces.streams, "unsupported-version")
StreamErrorCondition.SEE_OTHER_HOST.xso_class.new_address = xso.Text()
class StreamError(ConnectionError):
def __init__(self, condition, text=None):
if not isinstance(condition, StreamErrorCondition):
condition = StreamErrorCondition(condition)
warnings.warn(
"as of aioxmpp 1.0, stream error conditions must be members "
"of the aioxmpp.errors.StreamErrorCondition enumeration",
DeprecationWarning,
stacklevel=2,
)
super().__init__("stream error: {}".format(
format_error_text(condition, text))
)
self.condition = condition
self.text = text
class StanzaError(Exception):
pass
class XMPPError(StanzaError):
"""
Exception representing an error defined in the XMPP protocol.
:param condition: The :rfc:`6120` defined error condition as enumeration
member or :class:`aioxmpp.xso.XSO`
:type condition: :class:`aioxmpp.ErrorCondition` or
:class:`aioxmpp.xso.XSO`
:param text: Optional human-readable text explaining the error
:type text: :class:`str`
:param application_defined_condition: Object describing the error in more
detail
:type application_defined_condition: :class:`aioxmpp.xso.XSO`
.. versionchanged:: 0.10
As of 0.10, `condition` should either be a
:class:`aioxmpp.ErrorCondition` enumeration member or an XSO
representing one of the error conditions.
For compatibility, namespace-localpart tuples indicating the tag of
the defined error condition are still accepted.
.. deprecated:: 0.10
Starting with aioxmpp 1.0, namespace-localpart tuples will not be
accepted anymore. See the changelog for notes on the transition.
.. attribute:: condition_obj
The :class:`aioxmpp.XSO` which represents the error condition.
.. versionadded:: 0.10
.. autoattribute:: condition
.. attribute:: text
Optional human-readable text describing the error further.
This is :data:`None` if the text is omitted.
.. attribute:: application_defined_condition
Optional :class:`aioxmpp.XSO` which further defines the error
condition.
Relevant subclasses:
.. autosummary::
aioxmpp.XMPPAuthError
aioxmpp.XMPPModifyError
aioxmpp.XMPPCancelError
aioxmpp.XMPPContinueError
aioxmpp.XMPPWaitError
"""
TYPE = structs.ErrorType.CANCEL
def __init__(self,
condition,
text=None,
application_defined_condition=None):
if not isinstance(condition, (ErrorCondition, xso.XSO)):
condition = ErrorCondition(condition)
warnings.warn(
"as of aioxmpp 1.0, error conditions must be members of the "
"aioxmpp.ErrorCondition enumeration",
DeprecationWarning,
stacklevel=2,
)
super().__init__(format_error_text(
condition.enum_member,
text=text,
application_defined_condition=application_defined_condition))
self.condition_obj = condition.to_xso()
self.text = text
self.application_defined_condition = application_defined_condition
@property
def condition(self):
"""
:class:`aioxmpp.ErrorCondition` enumeration member representing the
error condition.
"""
return self.condition_obj.enum_member
class XMPPWarning(XMPPError, UserWarning):
TYPE = structs.ErrorType.CONTINUE
class XMPPAuthError(XMPPError, PermissionError):
TYPE = structs.ErrorType.AUTH
class XMPPModifyError(XMPPError, ValueError):
TYPE = structs.ErrorType.MODIFY
class XMPPCancelError(XMPPError):
TYPE = structs.ErrorType.CANCEL
class XMPPWaitError(XMPPError):
TYPE = structs.ErrorType.WAIT
class XMPPContinueError(XMPPWarning):
TYPE = structs.ErrorType.CONTINUE
class ErroneousStanza(StanzaError):
"""
This exception is thrown into listeners for IQ responses by
:class:`aioxmpp.stream.StanzaStream` if a response for an IQ was received,
but could not be decoded (due to malformed or unsupported payload).
.. attribute:: partial_obj
Contains the partially decoded stanza XSO. Do not rely on any members
except those representing XML attributes (:attr:`~.StanzaBase.to`,
:attr:`~.StanzaBase.from_`, :attr:`~.StanzaBase.type_`).
"""
def __init__(self, partial_obj):
super().__init__("erroneous stanza received: {!r}".format(
partial_obj))
self.partial_obj = partial_obj
class StreamNegotiationFailure(ConnectionError):
pass
class SecurityNegotiationFailure(StreamNegotiationFailure):
def __init__(self, xmpp_error,
kind="Security negotiation failure",
text=None):
msg = "{}: {}".format(kind, xmpp_error)
if text:
msg += " ('{}')".format(text)
super().__init__(msg)
self.xmpp_error = xmpp_error
self.text = text
class SASLUnavailable(SecurityNegotiationFailure):
# we use this to tell the Client that SASL has not been available at all,
# or that we could not agree on mechanisms.
# it might be helpful to notify the peer about this before dying.
pass
class TLSFailure(SecurityNegotiationFailure):
def __init__(self, xmpp_error, text=None):
super().__init__(xmpp_error, text=text, kind="TLS failure")
class TLSUnavailable(TLSFailure):
pass
class UserError(Exception):
"""
An exception subclass, which should be used as a mix-in.
It is intended to be used for exceptions which may be user-facing, such as
connection errors, value validation issues and the like.
`localizable_string` must be a :class:`.i18n.LocalizableString`
instance. The `args` and `kwargs` will be passed to
:class:`.LocalizableString.localize` when either :func:`str` is called on
the :class:`UserError` or :meth:`localize` is called.
The :func:`str` is created using the default
:class:`~.i18n.LocalizingFormatter` and a :class:`gettext.NullTranslations`
instance. The point in time at which the default localizing formatter is
created is unspecified.
.. automethod:: localize
"""
DEFAULT_FORMATTER = i18n.LocalizingFormatter()
DEFAULT_TRANSLATIONS = gettext.NullTranslations()
def __init__(self, localizable_string, *args, **kwargs):
super().__init__()
self._str = localizable_string.localize(
self.DEFAULT_FORMATTER,
self.DEFAULT_TRANSLATIONS,
*args, **kwargs)
self.localizable_string = localizable_string
self.args = args
self.kwargs = kwargs
def __str__(self):
return str(self._str)
def localize(self, formatter, translator):
"""
Return a localized version of the `localizable_string` passed to the
constructor. It is formatted using the `formatter` with the `args` and
`kwargs` passed to the constructor of :class:`UserError`.
"""
return self.localizable_string.localize(
formatter,
translator,
*self.args,
**self.kwargs
)
class UserValueError(UserError, ValueError):
"""
This is a :class:`ValueError` with :class:`UserError` mixed in.
"""
class MultiOSError(OSError):
"""
Describe an error situation which has been caused by the sequential
occurrence of multiple other `exceptions`.
The `message` shall be descriptive and will be prepended to a concatenation
of the error messages of the given `exceptions`.
"""
def __init__(self, message, exceptions):
flattened_exceptions = []
for exc in exceptions:
if hasattr(exc, "exceptions"):
flattened_exceptions.extend(exc.exceptions)
else:
flattened_exceptions.append(exc)
super().__init__(
"{}: multiple errors: {}".format(
message,
", ".join(map(str, flattened_exceptions))
)
)
self.exceptions = flattened_exceptions
class GatherError(RuntimeError):
"""
Describe an error situation which has been caused by the occurrence
of multiple other `exceptions`.
The `message` shall be descriptive and will be prepended to a concatenation
of the error messages of the given `exceptions`.
"""
def __init__(self, message, exceptions):
flattened_exceptions = []
for exc in exceptions:
if hasattr(exc, "exceptions"):
flattened_exceptions.extend(exc.exceptions)
else:
flattened_exceptions.append(exc)
super().__init__(
"{}: multiple errors: {}".format(
message,
", ".join(map(str, flattened_exceptions))
)
)
self.exceptions = flattened_exceptions
aioxmpp/forms/ 0000775 0000000 0000000 00000000000 14160146213 0013657 5 ustar 00root root 0000000 0000000 aioxmpp/forms/__init__.py 0000664 0000000 0000000 00000012736 14160146213 0016001 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.forms` --- Data Forms support (:xep:`4`)
#######################################################
This subpackage contains tools to deal with :xep:`4` Data Forms. Data Forms is
a pervasive and highly flexible protocol used in XMPP. It allows for
machine-readable (and processable) forms as well as tables of data. This
flexibility comes unfortunately at the price of complexity. This subpackage
attempts to take some of the load of processing Data Forms off the application
developer.
Cheat Sheet:
* The :class:`Form` class exists for use cases where automated processing of
Data Forms is supposed to happen. The
:ref:`api-aioxmpp.forms-declarative-style` allow convenient access to and
manipulation of form data from within code.
* Direct use of the :class:`Data` XSO is advisable if you want to present forms
or data to sentient beings: even though :class:`Form` is more convenient for
machine-to-machine use, using the :class:`Data` sent by the peer easily
allows showing the user *all* fields supported by the peer.
* For machine-processed tables, there is no tooling (yet).
.. versionadded:: 0.7
Even though the :mod:`aioxmpp.forms` module existed pre-0.7, it has not been
documented and was thus not part of the public API.
.. note::
The authors are not entirely happy with the API at some points.
Specifically, at some places where mutable data structures are used, the
mutation of these data structures may have unexpected side effects. This may
be rectified in a future release by replacing these data structures with
their appropriate immutable equivalents.
These locations are marked accordingly.
Attributes added to stanzas
===========================
:mod:`aioxmpp.forms` adds the following attributes to stanzas:
.. attribute:: aioxmpp.Message.xep0004_data
A sequence of :class:`Data` instances. This is used for example by the
:mod:`~.muc` implementation (:xep:`45`).
.. versionadded:: 0.8
.. _api-aioxmpp.forms-declarative-style:
Declarative-style Forms
=======================
Base class
----------
.. autoclass:: Form
Fields
------
Text fields
~~~~~~~~~~~
.. autoclass:: TextSingle(var, type_=xso.String(), *[, default=None][, required=False][, desc=None][, label=None])
.. autoclass:: TextPrivate(var, type_=xso.String(), *[, default=None][, required=False][, desc=None][, label=None])
.. autoclass:: TextMulti(var, type_=xso.String(), *[, default=()][, required=False][, desc=None][, label=None])
JID fields
~~~~~~~~~~
.. autoclass:: JIDSingle(var, *[, default=None][, required=False][, desc=None][, label=None])
.. autoclass:: JIDMulti(var, *[, default=()][, required=False][, desc=None][, label=None])
Selection fields
~~~~~~~~~~~~~~~~
.. autoclass:: ListSingle(var, type_=xso.String(), *[, default=None][, options=[]][, required=False][, desc=None][, label=None])
.. autoclass:: ListMulti(var, type_=xso.String(), *[, default=frozenset()][, options=[]][, required=False][, desc=None][, label=None])
Other fields
~~~~~~~~~~~~
.. autoclass:: Boolean(var, *[, default=False][, required=False][, desc=None][, label=None])
Abstract base classes
~~~~~~~~~~~~~~~~~~~~~
.. currentmodule:: aioxmpp.forms.fields
.. autoclass:: AbstractField
.. autoclass:: AbstractChoiceField(var, type_=xso.String(), *[, options=[]][, required=False][, desc=None][, label=None])
.. currentmodule:: aioxmpp.forms
.. _api-aioxmpp.forms-bound-fields:
Bound fields
============
Bound fields are objects which are returned when the descriptor attribute is
accessed on a form instance. It holds the value of the field, as well as
overrides for the default (specified on the descriptors themselves) values for
certain attributes (such as :attr:`~.AbstractField.desc`).
For the different field types, there are different classes of bound fields,
which are documented below.
.. currentmodule:: aioxmpp.forms.fields
.. autoclass:: BoundField
.. autoclass:: BoundSingleValueField
.. autoclass:: BoundMultiValueField
.. autoclass:: BoundOptionsField
.. autoclass:: BoundSelectField
.. autoclass:: BoundMultiSelectField
.. currentmodule:: aioxmpp.forms
XSOs
====
.. autoclass:: Data
.. autoclass:: DataType
.. autoclass:: Field
.. autoclass:: FieldType
Report and table support
------------------------
.. autoclass:: Reported
.. autoclass:: Item
""" # NOQA: E501
from . import xso # NOQA: F401
from .xso import ( # NOQA: F401
Data,
DataType,
Field,
FieldType,
Reported,
Item,
)
from .fields import ( # NOQA: F401
Boolean,
ListSingle,
ListMulti,
JIDSingle,
JIDMulti,
TextSingle,
TextMulti,
TextPrivate,
)
from .form import ( # NOQA: F401
Form,
)
aioxmpp/forms/fields.py 0000664 0000000 0000000 00000105521 14160146213 0015503 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: fields.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
# .
#
########################################################################
import abc
import collections
import copy
import aioxmpp.xso as xso
from . import xso as forms_xso
descriptor_ns = "{jabber:x:data}field"
FIELD_DOCSTRING_TEMPLATE = """
``{field_type.value}`` field ``{var}``
{label}
"""
class BoundField(metaclass=abc.ABCMeta):
"""
Abstract base class for objects returned by the field descriptors.
:param field: field descriptor to bind
:type field: :class:`AbstractField`
:param instance: form instance to bind to
:type instance: :class:`object`
:class:`BoundField` instances represent the connection between the field
descriptors present at a form *class* and the *instance* of that class.
They store of course the value of the field for the specific instance, but
also possible instance-specific overrides for the metadata attributes
:attr:`desc`, :attr:`label` and :attr:`required` (and possibly
:attr:`.BoundOptionsField.options`). By default, these attributes return
the same value as set on the corresponding `field`, but the attributes can
be set to different values, which only affects the single form instance.
The use case is to fill these fields with the information obtained from a
:class:`.Data` XSO when creating a form with :meth:`.Form.from_xso`: it
allows the fields to behave exactly like the sender specified.
Deep-copying a :class:`BoundField` deepcopies all attributes, except the
:attr:`field` and :attr:`instance` attributes. See also :meth:`clone_for`
for copying a bound field for a new `instance`.
Subclass overview:
.. autosummary::
BoundSingleValueField
BoundMultiValueField
BoundSelectField
BoundMultiSelectField
Binding relationship:
.. autoattribute:: field
.. attribute:: instance
The `instance` as passed to the constructor.
Field metadata attributes:
.. autoattribute:: desc
.. autoattribute:: label
.. autoattribute:: required
Helper methods:
.. automethod:: clone_for
The following methods must be implemented by base classes.
.. automethod:: load
.. automethod:: render
"""
def __init__(self, field, instance):
super().__init__()
self._field = field
self.instance = instance
@property
def field(self):
"""
The field which is bound to the :attr:`instance`.
"""
return self._field
def __repr__(self):
return "".format(
self._field,
self.instance,
id(self),
)
def __deepcopy__(self, memo):
result = copy.copy(self)
for k, v in self.__dict__.items():
if k == "_field" or k == "instance":
continue
setattr(result, k, copy.deepcopy(v, memo))
return result
@property
def desc(self):
"""
.. seealso::
:attr:`.Field.desc`
for a full description of the ``desc`` semantics.
"""
try:
return self._desc
except AttributeError:
return self._field.desc
@desc.setter
def desc(self, value):
self._desc = value
@property
def label(self):
"""
.. seealso::
:attr:`.Field.label`
for a full description of the ``label`` semantics.
"""
try:
return self._label
except AttributeError:
return self._field.label
@label.setter
def label(self, value):
self._label = value
@property
def required(self):
"""
.. seealso::
:attr:`.Field.required`
for a full description of the ``required`` semantics.
"""
try:
return self._required
except AttributeError:
return self._field.required
@required.setter
def required(self, value):
self._required = value
def clone_for(self, other_instance, memo=None):
"""
Clone this bound field for another instance, possibly during a
:func:`~copy.deepcopy` operation.
:param other_instance: Another form instance to which the newly created
bound field shall be bound.
:type other_instance: :class:`object`
:param memo: Optional deepcopy-memo (see :mod:`copy` for details)
If this is called during a deepcopy operation, passing the `memo` helps
preserving and preventing loops. This method is essentially a
deepcopy-operation, with a modification of the :attr:`instance`
afterwards.
"""
if memo is None:
result = copy.deepcopy(self)
else:
result = copy.deepcopy(self, memo)
result.instance = other_instance
return result
@abc.abstractmethod
def load(self, field_xso):
"""
Load the field information from a data field.
:param field_xso: XSO describing the field.
:type field_xso: :class:`~.Field`
This loads the current value, description, label and possibly options
from the `field_xso`, shadowing the information from the declaration of
the field on the class.
This method is must be overridden and is thus marked abstract. However,
when called from a subclass, it loads the :attr:`desc`, :attr:`label`
and :attr:`required` from the given `field_xso`. Subclasses are
supposed to implement a mechanism to load options and/or values from
the `field_xso` and then call this implementation through
:func:`super`.
"""
if field_xso.desc:
self._desc = field_xso.desc
if field_xso.label:
self._label = field_xso.label
self._required = field_xso.required
@abc.abstractmethod
def render(self, *, use_local_metadata=True):
"""
Return a :class:`~.Field` containing the values and metadata set in the
field.
:param use_local_metadata: if true, the description, label and required
metadata can be sourced from the field
descriptor associated with this bound field.
:type use_local_metadata: :class:`bool`
:return: A new :class:`~.Field` instance.
The returned object uses the values accessible through this object;
that means, any values set for e.g. :attr:`desc` take precedence over
the values declared at the class level. If `use_local_metadata` is
false, values declared at the class level are not used if no local
values are declared. This is useful when generating a reply to a form
received by a peer, as it avoids sending a modified form.
This method is must be overridden and is thus marked abstract. However,
when called from a subclass, it creates the :class:`~.Field` instance
and initialises its :attr:`~.Field.var`, :attr:`~.Field.type_`,
:attr:`~.Field.desc`, :attr:`~.Field.required` and
:attr:`~.Field.label` attributes and returns the result. Subclasses are
supposed to override this method, call the base implementation through
:func:`super` to obtain the :class:`~.Field` instance and then fill in
the values and/or options.
"""
result = forms_xso.Field(
var=self.field.var,
type_=self.field.FIELD_TYPE,
)
if use_local_metadata:
result.desc = self.desc
result.label = self.label
result.required = self.required
else:
try:
result.desc = self._desc
except AttributeError:
pass
try:
result.label = self._label
except AttributeError:
pass
try:
result.required = self._required
except AttributeError:
pass
return result
class BoundSingleValueField(BoundField):
"""
A bound field which has only a single value at any time. Only the first
value is parsed when loading data from a :class:`~.Field` XSO. When writing
data to a :class:`~.Field` XSO, :data:`None` is treated as the absence of
any value; every other value is serialised through the
:attr:`~.AbstractField.type_` of the field.
.. seealso::
:class:`BoundField`
for a description of the arguments.
This bound field is used by :class:`TextSingle`, :class:`TextPrivate` and
:class:`JIDSingle`.
.. autoattribute:: value
"""
@property
def value(self):
"""
The current value of the field. If no value is set when this attribute
is accessed for reading, the :meth:`default` of the field is invoked
and the result is set and returned as value.
Only values which pass through :meth:`~.AbstractCDataType.coerce` of
the :attr:`~.AbstractField.type_` of the field can be set. To
revert the :attr:`value` to its default, use the ``del`` operator.
"""
try:
return self._value
except AttributeError:
# call through to field
self._value = self._field.default()
return self._value
@value.setter
def value(self, value):
self._value = self._field.type_.coerce(value)
@value.deleter
def value(self):
try:
del self._value
except AttributeError:
pass
def load(self, field_xso):
try:
value = field_xso.values[0]
except IndexError:
value = self._field.default()
else:
value = self._field.type_.parse(value)
self._value = value
super().load(field_xso)
def render(self, **kwargs):
result = super().render(**kwargs)
try:
value = self._value
except AttributeError:
value = self._field.default()
if value is None:
return result
result.values[:] = [
self.field.type_.format(value)
]
return result
class BoundMultiValueField(BoundField):
"""
A bound field which can have multiple values.
.. seealso::
:class:`BoundField`
for a description of the arguments.
This bound field is used by :class:`TextMulti` and :class:`JIDMulti`.
.. autoattribute:: value
"""
@property
def value(self):
"""
A tuple of values. This attribute can be set with any iterable; the
iterable is then evaluated into a tuple and stored at the bound field.
Whenever values are written to this attribute, they are passed through
the :meth:`~.AbstractCDataType.coerce` method of the
:attr:`~.AbstractField.type_` of the field. To revert the
:attr:`value` to its default, use the ``del`` operator.
"""
try:
return self._value
except AttributeError:
self.value = self._field.default()
return self._value
@value.setter
def value(self, values):
coerce = self._field.type_.coerce
self._value = tuple(
coerce(v)
for v in values
)
@value.deleter
def value(self):
try:
del self._value
except AttributeError:
pass
def load(self, field_xso):
self._value = tuple(
self._field.type_.parse(v)
for v in field_xso.values
)
super().load(field_xso)
def render(self, **kwargs):
result = super().render(**kwargs)
result.values[:] = (
self.field.type_.format(v)
for v in self._value
)
return result
class BoundOptionsField(BoundField):
"""
This is an intermediate base class used to implement bound fields for
fields which have options from which one or more values must be chosen.
.. seealso::
:class:`BoundField`
for a description of the arguments.
When the field is loaded from a :class:`~.Field` XSO, the options are also
loaded from there and thus shadow the options defined at the `field`. This
may come to a surprise of code expecting a specific set of options.
Subclass overview:
.. autosummary::
BoundSelectField
BoundMultiSelectField
.. autoattribute:: options
"""
@property
def options(self):
"""
This is a :class:`collections.OrderedDict` which maps option keys to
their labels. The keys are used as the values of the field; the labels
are human-readable text for display.
This attribute can be written with any object which is compatible with
the dict-constructor. The order is preserved if a sequence of key-value
pairs is used.
When writing the attribute, the keys are checked against the
:meth:`~.AbstractCDataType.coerce` method of the
:attr:`~.AbstractField.type_` of the field. To make the :attr:`options`
attribute identical to the :attr:`~.AbstractField.options` attribute,
use the ``del`` operator.
.. warning::
This attribute is mutable, however, mutating it directly may have
unexpected side effects:
* If the attribute has not been set before, you will actually be
mutating the :class:`~.AbstractChoiceField.options` attributes
value.
This may be changed in the future by copying more eagerly.
* The type checking cannot take place when keys are added by direct
mutation of the dictionary. This means that errors will be delayed
until the actual serialisation of the data form, which may be a
confusing thing to debug.
Relying on the above behaviour or any other behaviour induced by
directly mutating the value returned by this attribute is **not
recommended**. Changes to this behaviour are *not* considered
breaking changes and will be done without the usual deprecation.
"""
try:
return self._options
except AttributeError:
return self.field.options
@options.setter
def options(self, value):
iterator = (value.items()
if isinstance(value, collections.abc.Mapping)
else value)
self._options = collections.OrderedDict(
(self.field.type_.coerce(k), v)
for k, v in iterator
)
@options.deleter
def options(self):
try:
del self._options
except AttributeError:
pass
def load(self, field_xso):
self._options = collections.OrderedDict(
field_xso.options
)
super().load(field_xso)
def render(self, **kwargs):
format_ = self._field.type_.format
field_xso = super().render(**kwargs)
field_xso.options.update(
(format_(k), v)
for k, v in self.options.items()
)
return field_xso
class BoundSelectField(BoundOptionsField):
"""
Bound field carrying one value out of a set of options.
.. seealso::
:class:`BoundField`
for a description of the arguments.
:attr:`BoundOptionsField.options`
for semantics and behaviour of the ``options`` attribute
.. autoattribute:: value
"""
@property
def value(self):
"""
The current value of the field. If no value is set when this attribute
is accessed for reading, the :meth:`default` of the field is invoked
and the result is set and returned as value.
Only values contained in the :attr:`~.BoundOptionsField.options` can be
set, other values are rejected with a :class:`ValueError`. To revert
the value to the default value specified in the descriptor, use the
``del`` operator.
"""
try:
return self._value
except AttributeError:
self._value = self.field.default()
return self._value
@value.setter
def value(self, value):
options = self.options
if value not in options:
raise ValueError("{!r} not in field options: {!r}".format(
value,
tuple(options.keys()),
))
self._value = value
@value.deleter
def value(self):
try:
del self._value
except AttributeError:
pass
def load(self, field_xso):
try:
value = field_xso.values[0]
except IndexError:
try:
del self._value
except AttributeError:
pass
else:
self._value = self.field.type_.parse(value)
super().load(field_xso)
def render(self, **kwargs):
format_ = self._field.type_.format
field_xso = super().render(**kwargs)
value = self.value
if value is not None:
field_xso.values[:] = [format_(value)]
return field_xso
class BoundMultiSelectField(BoundOptionsField):
"""
Bound field carrying a subset of values out of a set of options.
.. seealso::
:class:`BoundField`
for a description of the arguments.
:attr:`BoundOptionsField.options`
for semantics and behaviour of the ``options`` attribute
.. autoattribute:: value
"""
@property
def value(self):
"""
A :class:`frozenset` whose elements are a subset of the keys of the
:attr:`~.BoundOptionsField.options` mapping.
This value can be written with any iterable; the iterable is then
evaluated into a :class:`frozenset`. If it contains any value not
contained in the set of keys of options, the attribute is not written
and :class:`ValueError` is raised.
To revert the value to the default specified by the field descriptor,
use the ``del`` operator.
"""
try:
return self._value
except AttributeError:
self.value = self.field.default()
return self._value
@value.setter
def value(self, values):
new_values = frozenset(values)
options = set(self.options.keys())
invalid = new_values - options
if invalid:
raise ValueError(
"{!r} not in field options: {!r}".format(
next(iter(invalid)),
tuple(self.options.keys())
)
)
self._value = new_values
@value.deleter
def value(self):
try:
del self._value
except AttributeError:
pass
def load(self, field_xso):
self._value = frozenset(
self.field.type_.parse(value)
for value in field_xso.values
)
super().load(field_xso)
def render(self, **kwargs):
format_ = self.field.type_.format
result = super().render(**kwargs)
result.values[:] = [
format_(value)
for value in self.value
]
return result
class AbstractDescriptor(metaclass=abc.ABCMeta):
attribute_name = None
root_class = None
@abc.abstractmethod
def descriptor_keys(self):
"""
Return an iterator with the descriptor keys for this descriptor. The
keys will be added to the :attr:`DescriptorClass.DESCRIPTOR_KEYS`
mapping, pointing to the descriptor.
Duplicate keys will lead to a :class:`TypeError` being raised during
declaration of the class.
"""
class AbstractField(AbstractDescriptor):
"""
Abstract base class to implement field descriptor classes.
:param var: The field ``var`` attribute this descriptor is supposed to
represent.
:type var: :class:`str`
:param type_: The type of the data, defaults to :class:`~.xso.String`.
:type type_: :class:`~.xso.AbstractCDataType`
:param required: Flag to indicate that the field is required.
:type required: :class:`bool`
:param desc: Description text for the field, e.g. for tool-tips.
:type desc: :class:`str`, without newlines
:param label: Short, human-readable label for the field
:type label: :class:`str`
The arguments are used to initialise the respective attributes. Details on
the semantics can be found in the respective documentation pieces below.
.. autoattribute:: desc
.. attribute:: label
Represents the label flag as specified per :xep:`4`.
The value of this attribute is used when forms are generated locally.
When forms are received from remote peers and :class:`~.Form` instances
are constructed from that data, this attribute is not used when
rendering a reply or when the value is accessed through the bound field.
.. seealso::
:attr:`~.Field.label`
for details on the semantics of this attribute
.. attribute:: required
Represents the required flag as specified per :xep:`4`.
The value of this attribute is used when forms are generated locally.
When forms are received from remote peers and :class:`~.Form` instances
are constructed from that data, this attribute is not used when
rendering a reply or when the value is accessed through the bound field.
.. seealso::
:attr:`~.Field.required`
for details on the semantics of this attribute
.. autoattribute:: var
.. automethod:: create_bound
.. automethod:: default
.. automethod:: make_bound
"""
def __init__(self, var, type_, *, required=False, desc=None, label=None):
super().__init__()
self._var = var
self.required = required
self.desc = desc
self.label = label
self._type = type_
self.__doc__ = FIELD_DOCSTRING_TEMPLATE.format(
field_type=self.FIELD_TYPE,
var=self.var,
desc=self.desc,
label=self.label,
)
def descriptor_keys(self):
yield descriptor_ns, self._var
@property
def type_(self):
"""
:class:`.AbstractCDataType` instance used to parse, validate and
format the value(s) of this field.
The type of a field cannot be changed after its initialisation.
"""
return self._type
@property
def desc(self):
"""
Represents the description as specified per :xep:`4`.
The value of this attribute is used when forms are generated locally.
When forms are received from remote peers and :class:`~.Form` instances
are constructed from that data, this attribute is not used when
rendering a reply or when the value is accessed through the bound
field.
.. seealso::
:attr:`~.Field.desc`
for details on the semantics of this attribute
"""
return self._desc
@desc.setter
def desc(self, value):
if value is not None and any(ch == "\r" or ch == "\n" for ch in value):
raise ValueError("desc must not contain newlines")
self._desc = value
@desc.deleter
def desc(self):
self._desc = None
@property
def var(self):
"""
Represents the field ID as specified per :xep:`4`.
The value of this attribute is used to match fields when instantiating
:class:`~.Form` classes from :class:`~.Data` XSOs.
.. seealso::
:attr:`~.Field.var`
for details on the semantics of this attribute
"""
return self._var
@abc.abstractmethod
def default(self):
"""
Create and return a default value for this field.
This must be implemented by subclasses.
"""
@abc.abstractmethod
def create_bound(self, for_instance):
"""
Create a :ref:`bound field class `
instance for this field for the given form object and return it.
:param for_instance: The form instance to which the bound field should
be bound.
This method must be re-implemented by subclasses.
.. seealso::
:meth:`make_bound`
creates (using this method) or returns an existing bound field
for a given form instance.
"""
def make_bound(self, for_instance):
"""
Create a new :ref:`bound field class `
or return an existing one for the given form object.
:param for_instance: The form instance to which the bound field should
be bound.
If no bound field can be found on the given `for_instance` for this
field, a new one is created using :meth:`create_bound`, stored at the
instance and returned. Otherwise, the existing instance is returned.
.. seealso::
:meth:`create_bound`
creates a new bound field for the given form instance (without
storing it anywhere).
"""
try:
return for_instance._descriptor_data[self]
except KeyError:
bound = self.create_bound(for_instance)
for_instance._descriptor_data[self] = bound
return bound
def __get__(self, instance, type_):
if instance is None:
return self
return self.make_bound(instance)
class TextSingle(AbstractField):
"""
Represent a ``"text-single"`` input with the given `var`.
:param default: A default value to initialise the field.
.. seealso::
:class:`~.fields.BoundSingleValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractField`
for documentation on the `var`, `type_`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.TEXT_SINGLE
def __init__(self, var, type_=xso.String(), *,
default=None,
**kwargs):
super().__init__(var, type_, **kwargs)
self._default = default
def default(self):
return self._default
def create_bound(self, for_instance):
return BoundSingleValueField(
self,
for_instance,
)
class JIDSingle(AbstractField):
"""
Represent a ``"jid-single"`` input with the given `var`.
:param default: A default value to initialise the field.
.. seealso::
:class:`~.fields.BoundSingleValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractField`
for documentation on the `var`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.JID_SINGLE
def __init__(self, var, *, default=None, **kwargs):
super().__init__(var, type_=xso.JID(), **kwargs)
self._default = default
def default(self):
return self._default
def create_bound(self, for_instance):
return BoundSingleValueField(
self,
for_instance,
)
class Boolean(AbstractField):
"""
Represent a ``"boolean"`` input with the given `var`.
:param default: A default value to initialise the field.
.. seealso::
:class:`~.fields.BoundSingleValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractField`
for documentation on the `var`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.BOOLEAN
def __init__(self, var, *, default=False, **kwargs):
super().__init__(var, xso.Bool(), **kwargs)
self._default = default
def default(self):
return self._default
def create_bound(self, for_instance):
return BoundSingleValueField(
self,
for_instance,
)
class TextPrivate(TextSingle):
"""
Represent a ``"text-private"`` input with the given `var`.
:param default: A default value to initialise the field.
.. seealso::
:class:`~.fields.BoundSingleValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractField`
for documentation on the `var`, `type_`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.TEXT_PRIVATE
class TextMulti(AbstractField):
"""
Represent a ``"text-multi"`` input with the given `var`.
:param default: A default value to initialise the field.
:type default: :class:`tuple`
.. seealso::
:class:`~.fields.BoundMultiValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractField`
for documentation on the `var`, `type_`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.TEXT_MULTI
def __init__(self, var, type_=xso.String(), *,
default=(), **kwargs):
super().__init__(var, type_, **kwargs)
self._default = default
def create_bound(self, for_instance):
return BoundMultiValueField(self, for_instance)
def default(self):
return self._default
class JIDMulti(AbstractField):
"""
Represent a ``"jid-multi"`` input with the given `var`.
:param default: A default value to initialise the field.
:type default: :class:`tuple`
.. seealso::
:class:`~.fields.BoundMultiValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractField`
for documentation on the `var`, `type_`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.JID_MULTI
def __init__(self, var, *, default=(), **kwargs):
super().__init__(var, xso.JID(), **kwargs)
self._default = default
def create_bound(self, for_instance):
return BoundMultiValueField(self, for_instance)
def default(self):
return self._default
class AbstractChoiceField(AbstractField):
"""
Abstract base class to implement field descriptor classes using options.
:param type_: Type used for the option keys.
:type type_: :class:`~.xso.AbstractCDataType`
:param options: A sequence of key-value pairs or a mapping object
representing the options available.
:type options: sequence of pairs or mapping
The keys of the `options` mapping (or the first elements in the pairs in
the sequence of pairs) must be compatible with `type_`, in the sense that
must pass through :meth:`~.xso.AbstractCDataType.coerce` (this is enforced
when the field is instantiated).
Fields using this base class:
.. autosummary::
aioxmpp.forms.ListSingle
aioxmpp.forms.ListMulti
.. seealso::
:class:`~.fields.BoundOptionsField`
is an abstract base class to implement :ref:`bound field classes
` for fields inheriting from this
class.
:class:`~.fields.AbstractField`
for documentation on the `var`, `required`, `desc` and `label`
arguments.
"""
def __init__(self, var, *,
type_=xso.String(),
options=[],
**kwargs):
super().__init__(var, type_, **kwargs)
iterator = (options.items()
if isinstance(options, collections.abc.Mapping)
else options)
self.options = collections.OrderedDict(
(type_.coerce(k), v)
for k, v in iterator
)
self._type = type_
@property
def type_(self):
return self._type
class ListSingle(AbstractChoiceField):
"""
Represent a ``"list-single"`` input with the given `var`.
:param default: A default value to initialise the field. This must be a
member of the `options`.
.. seealso::
:class:`~.fields.BoundMultiValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractChoiceField`
for documentation on the `options` argument.
:class:`~.fields.AbstractField`
for documentation on the `var`, `type_`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.LIST_SINGLE
def __init__(self, var, *, default=None, **kwargs):
super().__init__(var, **kwargs)
if default is not None and default not in self.options:
raise ValueError("invalid default: not in options")
self._default = default
def create_bound(self, for_instance):
return BoundSelectField(self, for_instance)
def default(self):
return self._default
class ListMulti(AbstractChoiceField):
"""
Represent a ``"list-multi"`` input with the given `var`.
:param default: An iterable of `options` keys
:type default: iterable
`default` is evaluated into a :class:`frozenset` and all elements must be
keys of the `options` mapping argument.
.. seealso::
:class:`~.fields.BoundMultiValueField`
is the :ref:`bound field class ` used
by fields of this type.
:class:`~.fields.AbstractChoiceField`
for documentation on the `options` argument.
:class:`~.fields.AbstractField`
for documentation on the `var`, `type_`, `required`, `desc` and
`label` arguments.
"""
FIELD_TYPE = forms_xso.FieldType.LIST_MULTI
def __init__(self, var, *, default=frozenset(), **kwargs):
super().__init__(var, **kwargs)
self._default = frozenset(default)
if any(value not in self.options for value in self._default):
raise ValueError(
"invalid default: not in options"
)
def create_bound(self, for_instance):
return BoundMultiSelectField(self, for_instance)
def default(self):
return self._default
aioxmpp/forms/form.py 0000664 0000000 0000000 00000037623 14160146213 0015207 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: form.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
# .
#
########################################################################
import abc
import copy
from . import xso as forms_xso
from . import fields as fields
def descriptor_attr_name(descriptor):
return "_descriptor_{:x}".format(id(descriptor))
class DescriptorClass(abc.ABCMeta):
@classmethod
def _merge_descriptors(mcls, dest_map, source):
for key, (descriptor, from_class) in source:
try:
existing_descriptor, exists_at_class = dest_map[key]
except KeyError:
pass
else:
if descriptor is not existing_descriptor:
raise TypeError(
"descriptor with key {!r} already "
"declared at {}".format(
key,
exists_at_class,
)
)
else:
continue
dest_map[key] = descriptor, from_class
@classmethod
def _upcast_descriptor_map(mcls, descriptor_map, from_class):
return {
key: (descriptor, from_class)
for key, descriptor in descriptor_map.items()
}
def __new__(mcls, name, bases, namespace, *, protect=True):
descriptor_info = {}
for base in bases:
if not isinstance(base, DescriptorClass):
continue
base_descriptor_info = mcls._upcast_descriptor_map(
base.DESCRIPTOR_MAP,
"{}.{}".format(
base.__module__,
base.__qualname__,
)
)
mcls._merge_descriptors(
descriptor_info,
base_descriptor_info.items(),
)
fqcn = "{}.{}".format(
namespace["__module__"],
namespace["__qualname__"],
)
descriptors = [
(attribute_name, descriptor)
for attribute_name, descriptor in namespace.items()
if isinstance(descriptor, fields.AbstractDescriptor)
]
if any(descriptor.root_class is not None
for _, descriptor in descriptors):
raise ValueError(
"descriptor cannot be used on multiple classes"
)
mcls._merge_descriptors(
descriptor_info,
(
(key, (descriptor, fqcn))
for _, descriptor in descriptors
for key in descriptor.descriptor_keys()
)
)
namespace["DESCRIPTOR_MAP"] = {
key: descriptor
for key, (descriptor, _) in descriptor_info.items()
}
namespace["DESCRIPTORS"] = set(namespace["DESCRIPTOR_MAP"].values())
if "__slots__" not in namespace and protect:
namespace["__slots__"] = ()
result = super().__new__(mcls, name, bases, namespace)
for attribute_name, descriptor in descriptors:
descriptor.attribute_name = attribute_name
descriptor.root_class = result
return result
def __init__(self, name, bases, namespace, *, protect=True):
super().__init__(name, bases, namespace)
def _is_descriptor_attribute(self, name):
try:
existing = getattr(self, name)
except AttributeError:
pass
else:
if isinstance(existing, fields.AbstractDescriptor):
return True
return False
def __setattr__(self, name, value):
if self._is_descriptor_attribute(name):
raise AttributeError("descriptor attributes cannot be set")
if not isinstance(value, fields.AbstractDescriptor):
return super().__setattr__(name, value)
if self.__subclasses__():
raise TypeError("cannot add descriptors to classes with "
"subclasses")
meta = type(self)
descriptor_info = meta._upcast_descriptor_map(
self.DESCRIPTOR_MAP,
"{}.{}".format(self.__module__, self.__qualname__),
)
new_descriptor_info = [
(key, (value, ""))
for key in value.descriptor_keys()
]
# this would raise on conflict
meta._merge_descriptors(
descriptor_info,
new_descriptor_info,
)
for key, (descriptor, _) in new_descriptor_info:
self.DESCRIPTOR_MAP[key] = descriptor
self.DESCRIPTORS.add(value)
return super().__setattr__(name, value)
def __delattr__(self, name):
if self._is_descriptor_attribute(name):
raise AttributeError("removal of descriptors is not allowed")
return super().__delattr__(name)
def _register_descriptor_keys(self, descriptor, keys):
"""
Register the given descriptor keys for the given descriptor at the
class.
:param descriptor: The descriptor for which the `keys` shall be
registered.
:type descriptor: :class:`AbstractDescriptor` instance
:param keys: An iterable of descriptor keys
:raises TypeError: if the specified keys are already handled by a
descriptor.
:raises TypeError: if this class has subclasses or if it is not the
:attr:`~AbstractDescriptor.root_class` of the given
descriptor.
If the method raises, the caller must assume that registration was not
successful.
.. note::
The intended audience for this method are developers of
:class:`AbstractDescriptor` subclasses, which are generally only
expected to live in the :mod:`aioxmpp` package.
Thus, you should not expect this API to be stable. If you have a
use-case for using this function outside of :mod:`aioxmpp`, please
let me know through the usual issue reporting means.
"""
if descriptor.root_class is not self or self.__subclasses__():
raise TypeError(
"descriptors cannot be modified on classes with subclasses"
)
meta = type(self)
descriptor_info = meta._upcast_descriptor_map(
self.DESCRIPTOR_MAP,
"{}.{}".format(self.__module__, self.__qualname__),
)
# this would raise on conflict
meta._merge_descriptors(
descriptor_info,
[
(key, (descriptor, ""))
for key in keys
]
)
for key in keys:
self.DESCRIPTOR_MAP[key] = descriptor
class FormClass(DescriptorClass):
def from_xso(self, xso):
"""
Construct and return an instance from the given `xso`.
.. note::
This is a static method (classmethod), even though sphinx does not
document it as such.
:param xso: A :xep:`4` data form
:type xso: :class:`~.Data`
:raises ValueError: if the ``FORM_TYPE`` mismatches
:raises ValueError: if field types mismatch
:return: newly created instance of this class
The fields from the given `xso` are matched against the fields on the
form. Any matching field loads its data from the `xso` field. Fields
which occur on the form template but not in the `xso` are skipped.
Fields which occur in the `xso` but not on the form template are also
skipped (but are re-emitted when the form is rendered as reply, see
:meth:`~.Form.render_reply`).
If the form template has a ``FORM_TYPE`` attribute and the incoming
`xso` also has a ``FORM_TYPE`` field, a mismatch between the two values
leads to a :class:`ValueError`.
The field types of matching fields are checked. If the field type on
the incoming XSO may not be upcast to the field type declared on the
form (see :meth:`~.FieldType.allow_upcast`), a :class:`ValueError` is
raised.
If the :attr:`~.Data.type_` does not indicate an actual form (but
rather a cancellation request or tabular result), :class:`ValueError`
is raised.
"""
my_form_type = getattr(self, "FORM_TYPE", None)
f = self()
for field in xso.fields:
if field.var == "FORM_TYPE":
if (my_form_type is not None and
field.type_ == forms_xso.FieldType.HIDDEN and
field.values):
if my_form_type != field.values[0]:
raise ValueError(
"mismatching FORM_TYPE ({!r} != {!r})".format(
field.values[0],
my_form_type,
)
)
continue
if field.var is None:
continue
key = fields.descriptor_ns, field.var
try:
descriptor = self.DESCRIPTOR_MAP[key]
except KeyError:
continue
if (field.type_ is not None and not
field.type_.allow_upcast(descriptor.FIELD_TYPE)):
raise ValueError(
"mismatching type ({!r} != {!r}) on field var={!r}".format(
field.type_,
descriptor.FIELD_TYPE,
field.var,
)
)
data = descriptor.__get__(f, self)
data.load(field)
f._recv_xso = xso
return f
class Form(metaclass=FormClass):
"""
A form template for :xep:`0004` Data Forms.
Fields are declared using the different field descriptors available in this
module:
.. autosummary::
TextSingle
TextMulti
TextPrivate
JIDSingle
JIDMulti
ListSingle
ListMulti
Boolean
A form template can be instantiated by two different means:
1. the :meth:`from_xso` method can be called on a :class:`.xso.Data`
instance to fill in the template with the data from the XSO.
2. the constructor can be called.
With the first method, labels, descriptions, options and values are taken
from the XSO. The descriptors declared on the form merely act as a
convenient way to access the fields in the XSO.
If a field is missing from the XSO, its descriptor still works as if the
form had been constructed using its constructor. It will not be emitted
when re-serialising the form for a response using :meth:`render_reply`.
If the XSO has more fields than the form template, these fields are
re-emitted when the form is serialised using :meth:`render_reply`.
.. attribute:: LAYOUT
A mixed list of descriptors and strings to determine form layout as
generated by :meth:`render_request`. The semantics are the following:
* each :class:`str` is converted to a ``"fixed"`` field without ``var``
attribute in the output.
* each :class:`AbstractField` descriptor is rendered to its
corresponding :class:`Field` XSO.
The elements of :attr:`LAYOUT` are processed in-order. This attribute is
optional and can be set on either the :class:`Form` or a specific
instance. If it is absent, it is treated as if it were set to
``list(self.DESCRIPTORS)``.
.. automethod:: from_xso
.. automethod:: render_reply
.. automethod:: render_request
"""
__slots__ = ("_descriptor_data", "_recv_xso")
def __new__(cls, *args, **kwargs):
result = super().__new__(cls)
result._descriptor_data = {}
result._recv_xso = None
return result
def __copy__(self):
result = type(self).__new__(type(self))
result._descriptor_data.update(self._descriptor_data)
return result
def __deepcopy__(self, memo):
result = type(self).__new__(type(self))
result._descriptor_data = {
k: v.clone_for(self, memo=memo)
for k, v in self._descriptor_data.items()
}
return result
def render_reply(self):
"""
Create a :class:`~.Data` object equal to the object from which the from
was created through :meth:`from_xso`, except that the values of the
fields are exchanged with the values set on the form.
Fields which have no corresponding form descriptor are left untouched.
Fields which are accessible through form descriptors, but are not in
the original :class:`~.Data` are not included in the output.
This method only works on forms created through :meth:`from_xso`.
The resulting :class:`~.Data` instance has the :attr:`~.Data.type_` set
to :attr:`~.DataType.SUBMIT`.
"""
data = copy.copy(self._recv_xso)
data.type_ = forms_xso.DataType.SUBMIT
data.fields = list(self._recv_xso.fields)
for i, field_xso in enumerate(data.fields):
if field_xso.var is None:
continue
if field_xso.var == "FORM_TYPE":
continue
key = fields.descriptor_ns, field_xso.var
try:
descriptor = self.DESCRIPTOR_MAP[key]
except KeyError:
continue
bound_field = descriptor.__get__(self, type(self))
data.fields[i] = bound_field.render(
use_local_metadata=False
)
return data
def render_request(self):
"""
Create a :class:`Data` object containing all fields known to the
:class:`Form`. If the :class:`Form` has a :attr:`LAYOUT` attribute, it
is used during generation.
"""
data = forms_xso.Data(type_=forms_xso.DataType.FORM)
try:
layout = self.LAYOUT
except AttributeError:
layout = list(self.DESCRIPTORS)
my_form_type = getattr(self, "FORM_TYPE", None)
if my_form_type is not None:
field_xso = forms_xso.Field()
field_xso.var = "FORM_TYPE"
field_xso.type_ = forms_xso.FieldType.HIDDEN
field_xso.values[:] = [my_form_type]
data.fields.append(field_xso)
for item in layout:
if isinstance(item, str):
field_xso = forms_xso.Field()
field_xso.type_ = forms_xso.FieldType.FIXED
field_xso.values[:] = [item]
else:
field_xso = item.__get__(
self, type(self)
).render()
data.fields.append(field_xso)
return data
def _layout(self, usecase):
"""
Return an iterable of form members which are used to lay out the form.
:param usecase: Configure the use case of the layout. This either
indicates transmitting the form to a peer as
*response*, as *initial form*, or as *error form*, or
*showing* the form to a local user.
Each element in the iterable must be one of the following:
* A string; gets converted to a ``"fixed"`` form field.
* A field XSO; gets used verbatimly
* A descriptor; gets converted to a field XSO
"""
aioxmpp/forms/xso.py 0000664 0000000 0000000 00000046064 14160146213 0015054 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import collections
import enum
import aioxmpp
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0004_data = "jabber:x:data"
class Value(xso.XSO):
TAG = (namespaces.xep0004_data, "value")
value = xso.Text(default="")
class ValueElement(xso.AbstractElementType):
def unpack(self, item):
return item.value
def pack(self, value):
v = Value()
v.value = value
return v
def get_xso_types(self):
return [Value]
class Option(xso.XSO):
TAG = (namespaces.xep0004_data, "option")
label = xso.Attr(
tag="label",
default=None,
)
value = xso.ChildText(
(namespaces.xep0004_data, "value"),
default=None,
)
def validate(self):
if self.value is None:
raise ValueError("option is missing a value")
class OptionElement(xso.AbstractElementType):
def unpack(self, item):
return (item.value, item.label)
def pack(self, value):
value, label = value
o = Option()
o.value = value
o.label = label
return o
def get_xso_types(self):
return [Option]
class FieldType(enum.Enum):
"""
Enumeration containing the field types defined in :xep:`4`.
.. seealso::
:attr:`Field.values`
for important information regarding typing, restrictions, validation
and constraints of values in that attribute.
Each type has the following attributes and methods:
.. automethod:: allow_upcast
.. autoattribute:: has_options
.. autoattribute:: is_multivalued
Quotations in the following attribute descriptions are from said XEP.
.. attribute:: BOOLEAN
The ``"boolean"`` field:
The field enables an entity to gather or provide an either-or choice
between two options. The default value is "false".
The :attr:`Field.values` sequence should contain zero or one elements.
If it contains an element, it must be ``"0"``, ``"1"``, ``"false"``, or
``"true"``, in accordance with the XML Schema documents.
.. attribute:: FIXED
The ``"fixed"`` field:
The field is intended for data description (e.g., human-readable text
such as "section" headers) rather than data gathering or provision.
The child SHOULD NOT contain newlines (the ``\\n`` and
``\\r`` characters); instead an application SHOULD generate multiple
fixed fields, each with one child.
As such, the :attr:`Field.values` sequence should contain exactly one
element. :attr:`Field.desc`, :attr:`Field.label`, :attr:`Field.options`
and :attr:`Field.var` should be set to :data:`None` or empty containers.
.. attribute:: HIDDEN
The ``"hidden"`` field:
The field is not shown to the form-submitting entity, but instead is
returned with the form. The form-submitting entity SHOULD NOT modify
the value of a hidden field, but MAY do so if such behavior is
defined for the "using protocol".
This type is commonly used for the ``var="FORM_TYPE"`` field, as
specified in :xep:`68`.
.. attribute:: JID_MULTI
The ``"jid-multi"`` field:
The field enables an entity to gather or provide multiple Jabber IDs.
Each provided JID SHOULD be unique (as determined by comparison that
includes application of the Nodeprep, Nameprep, and Resourceprep
profiles of Stringprep as specified in XMPP Core), and duplicate JIDs
MUST be ignored.
As such, the :attr:`Field.values` sequence should contain zero or more
strings representing Jabber IDs. :attr:`Field.options` should be empty.
.. attribute:: JID_SINGLE
The ``"jid-single"`` field:
The field enables an entity to gather or provide a single Jabber ID.
As such, the :attr:`Field.values` sequence should contain zero or one
string representing a Jabber ID. :attr:`Field.options` should be empty.
.. attribute:: LIST_MULTI
The ``"list-multi"`` field:
The field enables an entity to gather or provide one or more options
from among many. A form-submitting entity chooses one or more items
from among the options presented by the form-processing entity and
MUST NOT insert new options. The form-submitting entity MUST NOT
modify the order of items as received from the form-processing
entity, since the order of items MAY be significant.
Thus, :attr:`Field.values` should contain a subset of the keys of the
:class:`Field.options` dictionary.
.. attribute:: LIST_SINGLE
The ``"list-single"`` field:
The field enables an entity to gather or provide one option from
among many. A form-submitting entity chooses one item from among the
options presented by the form-processing entity and MUST NOT insert
new options.
Thus, :attr:`Field.values` should contain a zero or one of the keys of
the :class:`Field.options` dictionary.
.. attribute:: TEXT_MULTI
The ``"text-multi"`` field:
The field enables an entity to gather or provide multiple lines of
text.
Each string in the :attr:`Field.values` attribute should be a single
line of text. Newlines are not allowed in data forms fields (due to the
ambiguity between ``\\r`` and ``\\n`` and combinations thereof), which
is why the text is split on the line endings.
.. attribute:: TEXT_PRIVATE
The ``"text-private"`` field:
The field enables an entity to gather or provide a single line or
word of text, which shall be obscured in an interface (e.g., with
multiple instances of the asterisk character).
The :attr:`Field.values` attribute should contain zero or one string
without any newlines.
.. attribute:: TEXT_SINGLE
The ``"text-single"`` field:
The field enables an entity to gather or provide a single line or
word of text, which may be shown in an interface. This field type is
the default and MUST be assumed if a form-submitting entity receives
a field type it does not understand.
The :attr:`Field.values` attribute should contain zero or one string
without any newlines.
"""
FIXED = "fixed"
HIDDEN = "hidden"
BOOLEAN = "boolean"
TEXT_SINGLE = "text-single"
TEXT_MULTI = "text-multi"
TEXT_PRIVATE = "text-private"
LIST_SINGLE = "list-single"
LIST_MULTI = "list-multi"
JID_SINGLE = "jid-single"
JID_MULTI = "jid-multi"
@property
def has_options(self):
"""
true for the ``list-`` field types, false otherwise.
"""
return self.value.startswith("list-")
@property
def is_multivalued(self):
"""
true for the ``-multi`` field types, false otherwise.
"""
return self.value.endswith("-multi")
def allow_upcast(self, to):
"""
Return true if the field type may be upcast to the other field type
`to`.
This relation specifies when it is safe to transfer data from this
field type to the given other field type `to`.
This is the case if any of the following holds true:
* `to` is equal to this type
* this type is :attr:`TEXT_SINGLE` and `to` is :attr:`TEXT_PRIVATE`
"""
if self == to:
return True
if self == FieldType.TEXT_SINGLE and to == FieldType.TEXT_PRIVATE:
return True
return False
class Field(xso.XSO):
"""
Represent a single field in a Data Form.
:param type_: Field type, must be one of the valid field types specified in
:xep:`4`.
:type type_: :class:`FieldType`
:param options: A mapping of values to labels defining the options in a
``list-*`` field.
:type options: :class:`dict` mapping :class:`str` to :class:`str`
:param values: A sequence of values currently given for the field. Having
more than one value is only valid in ``*-multi`` fields.
:type values: :class:`list` of :class:`str`
:param desc: Description which can be shown in a tool-tip or similar,
without newlines.
:type desc: :class:`str` or :data:`None`
:param label: Human-readable label to be shown next to the field input
:type label: :class:`str` or :data:`None`
:param required: Flag to indicate that the field is required
:type required: :class:`bool`
:param var: "ID" identifying the field uniquely inside the form. Only
required for fields carrying a meaning (thus, not for
``fixed``).
:type var: :class:`str` or :data:`None`
The semantics of a :class:`Field` are different depending on where it
occurs: in a :class:`Data`, it is a form field to be filled in, in a
:class:`Item` it is a cell of a row and in a :class:`Reported` it
represents a column header.
.. attribute:: required
A boolean flag indicating whether the field is required.
If true, the XML serialisation will contain the corresponding
```` tag.
.. attribute:: desc
Single line of description for the field. This attribute represents the
```` element from :xep:`4`.
.. attribute:: values
A sequence of strings representing the ```` elements of the
field, one string for each value.
.. note::
Since the requirements on the sequence of strings in :attr:`values`
change depending on the :attr:`type_` attribute, validation and type
conversion on assignment is very lax. The attribute accepts all
sequences of strings, even if the field is for example a
:attr:`FieldType.BOOLEAN` field, which allows for at most one string
of a well-defined format (see the documentation there for the
details).
This makes it easy to inadvertendly generate invalid forms, which is
why you should be using :class:`Form` subclasses when accessing forms
from within normal code and some other, generic mechanism taking care
of these details when showing forms in a UI framework to users. Note
that devising such a mechanism is out of scope for :mod:`aioxmpp`, as
every UI framework has different requirements.
.. attribute:: options
A dictionary mapping values to human-readable labels, representing the
```` elements of the field.
.. attribute:: var
The uniquely identifying string of the (valued, that is,
non-:attr:`FieldType.FIXED` field). Represents the ``var`` attribute of
the field.
.. attribute:: type_
The type of the field. The :attr:`type_` must be a :class:`FieldType`
enumeration value and determines restrictions and constraints on other
attributes. See the :class:`FieldType` enumeration and :xep:`4` for
details.
.. attribute:: label
The human-readable label for the field, representing the ``label``
attribute of the field. May be :data:`None` if the label is omitted.
"""
TAG = (namespaces.xep0004_data, "field")
required = xso.ChildFlag(
(namespaces.xep0004_data, "required"),
)
desc = xso.ChildText(
(namespaces.xep0004_data, "desc"),
default=None
)
values = xso.ChildValueList(
type_=ValueElement()
)
options = xso.ChildValueMap(
type_=OptionElement(),
mapping_type=collections.OrderedDict,
)
var = xso.Attr(
(None, "var"),
default=None
)
type_ = xso.Attr(
(None, "type"),
type_=xso.EnumCDataType(
FieldType,
),
default=None,
)
label = xso.Attr(
(None, "label"),
default=None
)
def __init__(self, *,
type_=FieldType.TEXT_SINGLE,
options={},
values=[],
desc=None,
label=None,
required=False,
var=None):
super().__init__()
self.type_ = type_
self.options.update(options)
self.values[:] = values
self.desc = desc
self.label = label
self.required = required
self.var = var
def validate(self):
super().validate()
if self.type_ != FieldType.FIXED and not self.var:
raise ValueError("missing attribute var")
if self.type_ is not None:
if not self.type_.has_options and self.options:
raise ValueError("unexpected option on non-list field")
if not self.type_.is_multivalued and len(self.values) > 1:
raise ValueError("too many values on non-multi field")
values_list = [opt for opt in self.options.values() if opt is not None]
values_set = set(values_list)
if len(values_list) != len(values_set):
raise ValueError("duplicate option label in {}".format(
values_list
))
class AbstractItem(xso.XSO):
fields = xso.ChildList([Field])
class Item(AbstractItem):
"""
A single row in a report :class:`Data` object.
.. attribute:: fields
A sequence of :class:`Field` objects representing the cells of the row.
"""
TAG = (namespaces.xep0004_data, "item")
class Reported(AbstractItem):
"""
The table heading of a report :class:`Data` object.
.. attribute:: fields
A sequence of :class:`Field` objects representing the columns of the
report or table.
"""
TAG = (namespaces.xep0004_data, "reported")
class Instructions(xso.XSO):
TAG = (namespaces.xep0004_data, "instructions")
value = xso.Text(default="")
class InstructionsElement(xso.AbstractElementType):
def unpack(self, item):
return item.value
def pack(self, value):
v = Instructions()
v.value = value
return v
def get_xso_types(self):
return [Instructions]
class DataType(enum.Enum):
"""
Enumeration containing the :class:`Data` types defined in :xep:`4`.
Quotations in the following attribute descriptions are from :xep:`4`.
.. attribute:: FORM
The ``"form"`` type:
The form-processing entity is asking the form-submitting entity to
complete a form.
.. attribute:: SUBMIT
The ``"submit"`` type:
The form-submitting entity is submitting data to the form-processing
entity. The submission MAY include fields that were not provided in
the empty form, but the form-processing entity MUST ignore any fields
that it does not understand.
.. attribute:: CANCEL
The ``"cancel"`` type:
The form-submitting entity has cancelled submission of data to the
form-processing entity.
.. attribute:: RESULT
The ``"result"`` type:
The form-processing entity is returning data (e.g., search results) to
the form-submitting entity, or the data is a generic data set.
"""
FORM = "form"
SUBMIT = "submit"
RESULT = "result"
CANCEL = "cancel"
class Data(AbstractItem):
"""
A :xep:`4` ``x`` element, that is, a Data Form.
:param type_: Initial value for the :attr:`type_` attribute.
.. attribute:: type_
The ``type`` attribute of the form, represented by one of the members of
the :class:`DataType` enumeration.
.. attribute:: title
The (optional) title of the form. Either a :class:`str` or :data:`None`.
.. attribute:: instructions
A sequence of strings which represent the instructions elements on the
form.
.. attribute:: fields
If the :class:`Data` is a form, this is a sequence of :class:`Field`
elements which represent the fields to be filled in.
This does not make sense on :attr:`.DataType.RESULT` typed objects.
.. attribute:: items
If the :class:`Data` is a table, this is a sequence of :class:`Item`
instances which represent the table rows.
This only makes sense on :attr:`.DataType.RESULT` typed objects.
.. attribute:: reported
If the :class:`Data` is a table, this is a :class:`Reported` object
representing the table header.
This only makes sense on :attr:`.DataType.RESULT` typed objects.
.. automethod:: get_form_type
"""
TAG = (namespaces.xep0004_data, "x")
type_ = xso.Attr(
"type",
type_=xso.EnumCDataType(DataType)
)
title = xso.ChildText(
(namespaces.xep0004_data, "title"),
default=None,
)
instructions = xso.ChildValueList(
type_=InstructionsElement()
)
items = xso.ChildList([Item])
reported = xso.Child([Reported], required=False)
def __init__(self, type_):
super().__init__()
self.type_ = type_
def _validate_result(self):
if self.fields:
raise ValueError("field in report result")
fieldvars = {field.var for field in self.reported.fields}
if not fieldvars:
raise ValueError("empty report header")
for item in self.items:
itemvars = {field.var for field in item.fields}
if itemvars != fieldvars:
raise ValueError("field mismatch between row and header")
def validate(self):
super().validate()
if (self.type_ != DataType.RESULT and
(self.reported is not None or self.items)):
raise ValueError("report in non-result")
if (self.type_ == DataType.RESULT and
(self.reported is not None or self.items)):
self._validate_result()
def get_form_type(self):
"""
Extract the ``FORM_TYPE`` from the fields.
:return: ``FORM_TYPE`` value or :data:`None`
:rtype: :class:`str` or :data:`None`
Return :data:`None` if no well-formed ``FORM_TYPE`` field is found in
the list of fields.
.. versionadded:: 0.8
"""
for field in self.fields:
if field.var == "FORM_TYPE" and field.type_ == FieldType.HIDDEN:
if len(field.values) != 1:
return None
return field.values[0]
aioxmpp.Message.xep0004_data = xso.ChildList([Data])
aioxmpp/hashes.py 0000664 0000000 0000000 00000026360 14160146213 0014365 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: hashes.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.hashes` --- Hash Functions for use with XMPP (:xep:`300`)
########################################################################
:xep:`300` consolidates the use of hash functions and their digests in XMPP.
Identifiers (usually called `algo`) are defined to refer to specific
implementations and parametrisations of hashes (:func:`hash_from_algo`,
:func:`algo_of_hash`) and there is a defined XML format for carrying hash
digests (:class:`Hash`) and hash algorithms to be used (:class:`HashUsed`).
This allows other extensions to easily embed hash digests in their protocols
(:class:`HashesParent`, :class:`HashesUsedParent`).
The service :class:`HashService` registers the disco features for the
supported hash functions and allows querying hash functions supported by
you and another entity on the Jabber network supporting :xep:`300`.
.. note::
Compliance with :xep:`300` depends on your build of Python and possibly
OpenSSL. Version 0.5.1 of :xep:`300` requires support of SHA3 and BLAKE2b,
which was only introduced in Python 3.6.
Utilities for Working with Hash Algorithm Identifiers
=====================================================
.. autofunction:: hash_from_algo
.. autofunction:: algo_of_hash
.. data:: default_hash_algorithms
A set of `algo` values which consists of hash functions matching the
following criteria:
* They are specified as ``MUST`` or ``SHOULD`` in the supported version of
:xep:`300`.
* They are supported by :mod:`hashlib`.
* Only one function from each matching family is selected. If multiple
functions apply, ``MUST`` is preferred over ``SHOULD``.
The set thus varies based on the build of Python and possibly OpenSSL. The
algorithms in the set are guaranteed to return a valid hash implementation
when passed to :func:`~aioxmpp.misc.hash_from_algo`.
In a fully compliant build, this set consists of ``sha-256``, ``sha3-256``
and ``blake2b-256``.
Service
=======
.. autoclass:: HashService
XSOs
====
.. autoclass:: Hash
.. autoclass:: HashesParent()
.. autoclass:: HashUsed
.. autoclass:: HashesUsedParent()
"""
import asyncio
import hashlib
import aioxmpp.disco as disco
import aioxmpp.service as service
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0300_hashes2 = "urn:xmpp:hashes:2"
namespaces.xep0300_hash_name_prefix = "urn:xmpp:hash-function-text-names:"
_HASH_ALGO_MAPPING = [
("md2", (False, ("md2", (), {}))),
("md4", (False, ("md4", (), {}))),
("md5", (False, ("md5", (), {}))),
("sha-1", (True, ("sha1", (), {}))),
("sha-224", (True, ("sha224", (), {}))),
("sha-256", (True, ("sha256", (), {}))),
("sha-384", (True, ("sha384", (), {}))),
("sha-512", (True, ("sha512", (), {}))),
("sha3-256", (True, ("sha3_256", (), {}))),
("sha3-512", (True, ("sha3_512", (), {}))),
("blake2b-256", (True, ("blake2b", (), {"digest_size": 32}))),
("blake2b-512", (True, ("blake2b", (), {"digest_size": 64}))),
]
_HASH_ALGO_MAP = dict(_HASH_ALGO_MAPPING)
_HASH_ALGO_REVERSE_MAP = {
fun_name: (enabled, algo)
for algo, (enabled, (fun_name, fun_args, fun_kwargs)) in _HASH_ALGO_MAPPING
if not fun_args and not fun_kwargs
}
def is_algo_supported(algo):
try:
enabled, (fun_name, _, _) = _HASH_ALGO_MAP[algo]
except KeyError:
return False
return enabled and hasattr(hashlib, fun_name)
SUPPORTED_HASH_FEATURES = set()
for _hash in _HASH_ALGO_MAP:
if is_algo_supported(_hash):
SUPPORTED_HASH_FEATURES.add(
namespaces.xep0300_hash_name_prefix + _hash
)
del _hash
def hash_from_algo(algo):
"""
Return a :mod:`hashlib` hash given the :xep:`300` `algo`.
:param algo: The algorithm identifier as defined in :xep:`300`.
:type algo: :class:`str`
:raises NotImplementedError: if the hash algorithm is not supported by
:mod:`hashlib`.
:raises ValueError: if the hash algorithm MUST NOT be supported.
:return: A hash object from :mod:`hashlib` or compatible.
If the `algo` is not supported by the :mod:`hashlib` module,
:class:`NotImplementedError` is raised.
"""
try:
enabled, (fun_name, fun_args, fun_kwargs) = _HASH_ALGO_MAP[algo]
except KeyError:
raise NotImplementedError(
"hash algorithm {!r} unknown".format(algo)
) from None
if not enabled:
raise ValueError(
"support of {} in XMPP is forbidden".format(algo)
)
try:
fun = getattr(hashlib, fun_name)
except AttributeError as exc:
raise NotImplementedError(
"{} not supported by hashlib".format(algo)
) from exc
return fun(*fun_args, **fun_kwargs)
def algo_of_hash(h):
"""
Return a :xep:`300` `algo` from a given :mod:`hashlib` hash.
:param h: Hash object from :mod:`hashlib`.
:raises ValueError: if `h` does not have a defined `algo` value.
:raises ValueError: if the hash function MUST NOT be supported.
:return: The `algo` value for the given hash.
:rtype: :class:`str`
.. warning::
Use with caution for :func:`hashlib.blake2b` hashes.
:func:`algo_of_hash` cannot safely determine whether blake2b was
initialised with a salt, personality, key or other non-default
:xep:`300` mode.
In such a case, the return value will be the matching ``blake2b-*``
`algo`, but the digest will not be compatible with the results of other
implementations.
"""
try:
enabled, algo = _HASH_ALGO_REVERSE_MAP[h.name]
except KeyError:
pass
else:
if not enabled:
raise ValueError("support of {} in XMPP is forbidden".format(
algo
))
return algo
if h.name == "blake2b":
return "blake2b-{}".format(h.digest_size * 8)
raise ValueError(
"unknown hash implementation: {!r}".format(h)
)
class Hash(xso.XSO):
"""
Represent a single hash digest.
.. attribute:: algo
The hash algorithm used. The name is as specified in :xep:`300`.
.. attribute:: digest
The digest as :class:`bytes`.
"""
TAG = namespaces.xep0300_hashes2, "hash"
algo = xso.Attr(
"algo",
)
digest = xso.Text(
type_=xso.Base64Binary()
)
def __init__(self, algo, digest):
super().__init__()
self.algo = algo
self.digest = digest
def get_impl(self):
"""
Return a new :mod:`hashlib` hash for the :attr:`algo` set on this
object.
See :func:`hash_from_algo` for details and exceptions.
"""
return hash_from_algo(self.algo)
class HashUsed(xso.XSO):
"""
Represent a single hash-used algorithm spec.
.. attribute:: algo
The hash algorithm used. The name is as specified in :xep:`300`.
"""
TAG = namespaces.xep0300_hashes2, "hash-used"
algo = xso.Attr(
"algo",
)
def __init__(self, algo):
super().__init__()
self.algo = algo
def get_impl(self):
"""
Return a new :mod:`hashlib` hash for the :attr:`algo` set on this
object.
See :func:`hash_from_algo` for details and exceptions.
"""
return hash_from_algo(self.algo)
class HashType(xso.AbstractElementType):
@classmethod
def get_xso_types(cls):
return [Hash]
def unpack(self, obj):
return obj.algo, obj.digest
def pack(self, pair):
return Hash(*pair)
class HashesParent(xso.XSO):
"""
Mix-in class for XSOs which use :class:`Hash` children.
.. attribute:: digests
A mapping which maps from the :attr:`Hash.algo` to the
:attr:`Hash.digest`.
"""
digests = xso.ChildValueMap(
type_=HashType(),
)
class HashUsedType(xso.AbstractElementType):
@classmethod
def get_xso_types(cls):
return [HashUsed]
def unpack(self, obj):
return obj.algo
def pack(self, item):
return HashUsed(item)
class HashesUsedParent(xso.XSO):
"""
Mix-in class for XSOs which use :class:`HashUsed` children.
.. attribute:: algos
A list of hash algorithms.
"""
algos = xso.ChildValueList(
type_=HashUsedType(),
)
default_hash_algorithms = {
algo
for algo in ["sha-256", "sha3-256", "blake2b-256"]
if is_algo_supported(algo)
}
class HashService(service.Service):
"""
The service component of the :xep:`300` support. This service registers
the features and allows to query the hash functions supported by us and
a remote entity:
.. automethod:: select_common_hashes
"""
ORDER_AFTER = [
disco.DiscoClient,
disco.DiscoServer,
]
hashes_feature = disco.register_feature(namespaces.xep0300_hashes2)
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._disco_client = self.dependencies[disco.DiscoClient]
self._disco_server = self.dependencies[disco.DiscoServer]
for feature in SUPPORTED_HASH_FEATURES:
self._disco_server.register_feature(feature)
async def _shutdown(self):
for feature in SUPPORTED_HASH_FEATURES:
self._disco_server.unregister_feature(feature)
await super()._shutdown()
async def select_common_hashes(self, other_entity):
"""
Return the list of algos supported by us and `other_entity`. The
algorithms are represented by their :xep:`300` URNs
(`urn:xmpp:hash-function-text-names:...`).
:param other_entity: the address of another entity
:type other_entity: :class:`aioxmpp.JID`
:returns: the identifiers of the hash algorithms supported by
both us and the other entity
:rtype: :class:`set`
:raises RuntimeError: if the other entity does not support the
:xep:`300` feature nor does not publish hash functions
URNs we support.
Note: This assumes the protocol is supported if valid hash
function features are detected, even if `urn:xmpp:hashes:2` is
not listed as a feature.
"""
disco_info = await self._disco_client.query_info(other_entity)
intersection = disco_info.features & SUPPORTED_HASH_FEATURES
if (not intersection and
namespaces.xep0300_hashes2 not in disco_info.features):
raise RuntimeError(
"Remote does not support the urn:xmpp:hashes:2 feature.")
return intersection
aioxmpp/httpupload/ 0000775 0000000 0000000 00000000000 14160146213 0014715 5 ustar 00root root 0000000 0000000 aioxmpp/httpupload/__init__.py 0000664 0000000 0000000 00000006441 14160146213 0017033 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.httpupload` --- HTTP Upload support (:xep:`363`)
###############################################################
The :xep:`363` HTTP Upload protocol allows an XMPP client to obtain a PUT and
GET URL for storage on the server. It can upload a file (once) using the PUT
URL and distribute access to the file via the GET url.
This module does *not* handle the HTTP part of the interaction. We recommend
to use :mod:`aiohttp` for this, but you can use any HTTP library which supports
GET, PUT and sending custom headers.
Example use::
client =
http_upload_service =
slot = await client.send(aioxmpp.IQ(
type_=aioxmpp.IQType.GET,
to=http_upload_service,
payload=aioxmpp.httpupload.Request(
filename,
size,
content_type,
)
))
# http_put_file is provided by you via an HTTP library
await http_put_file(
slot.put.url,
slot.put.headers,
filename
)
.. autofunction:: request_slot
.. autoclass:: Request
.. module:: aioxmpp.httpupload.xso
.. currentmodule:: aioxmpp.httpupload.xso
.. autoclass:: Slot()
.. autoclass:: Get()
.. autoclass:: Put()
"""
import asyncio
from ..structs import JID, IQType
from ..stanza import IQ
from .xso import Request
async def request_slot(client,
service: JID,
filename: str,
size: int,
content_type: str):
"""
Request an HTTP upload slot.
:param client: The client to request the slot with.
:type client: :class:`aioxmpp.Client`
:param service: Address of the HTTP upload service.
:type service: :class:`~aioxmpp.JID`
:param filename: Name of the file (without path), may be used by the server
to generate the URL.
:type filename: :class:`str`
:param size: Size of the file in bytes
:type size: :class:`int`
:param content_type: The MIME type of the file
:type content_type: :class:`str`
:return: The assigned upload slot.
:rtype: :class:`.xso.Slot`
Sends a :xep:`363` slot request to the XMPP service to obtain HTTP
PUT and GET URLs for a file upload.
The upload slot is returned as a :class:`~.xso.Slot` object.
"""
payload = Request(filename, size, content_type)
return await client.send(IQ(
type_=IQType.GET,
to=service,
payload=payload
))
aioxmpp/httpupload/xso.py 0000664 0000000 0000000 00000011675 14160146213 0016112 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp.stanza
import aioxmpp.xso
from aioxmpp.utils import namespaces
namespaces.xep0363_http_upload = "urn:xmpp:http:upload:0"
@aioxmpp.IQ.as_payload_class
class Request(aioxmpp.xso.XSO):
"""
XSO to request an upload slot from the server.
The parameters initialise the attributes below.
.. attribute:: filename
:annotation: : str
The file name (without path, but possibly with "extension") of the
file to upload. The server MAY use this in the URL.
.. attribute:: size
:annotation: : int
The size of the file in bytes. This must be accurate and MUST also
be used as ``Content-Length`` header in the PUT request.
.. attribute:: content_type
:annotation: : str
The MIME type of the file. This MUST be set in the PUT request as
``Content-Type`` header.
"""
TAG = namespaces.xep0363_http_upload, "request"
filename = aioxmpp.xso.Attr("filename")
size = aioxmpp.xso.Attr(
"size",
type_=aioxmpp.xso.Integer(),
)
content_type = aioxmpp.xso.Attr("content-type")
def __init__(self, filename, size, content_type):
super().__init__()
self.filename = filename
self.size = size
self.content_type = content_type
class Header(aioxmpp.xso.XSO):
TAG = namespaces.xep0363_http_upload, "header"
name = aioxmpp.xso.Attr("name")
value = aioxmpp.xso.Text()
class HeaderType(aioxmpp.xso.AbstractElementType):
@staticmethod
def get_xso_types():
return (Header,)
@classmethod
def unpack(self, header_xso):
return header_xso.name, header_xso.value
@classmethod
def pack(self, t):
header_xso = Header()
header_xso.name, header_xso.value = t
return header_xso
class Put(aioxmpp.xso.XSO):
"""
.. attribute:: url
:annotation: : str
The URL against which the PUT request must be made.
.. attribute:: headers
:annotation: : multidict.MultiDict
The headers which MUST be used in the PUT request as
:class:`multidict.MultiDict`, in addition to the ``Content-Type``
and ``Content-Length`` headers.
The headers are already sanitised according to :xep:`363` (see also
:attr:`HEADER_WHITELIST`).
.. attribute:: HEADER_WHITELIST
This *class attribute* holds the list of headers which are allowed to
be used by the server. This defaults to the list specified in
:xep:`363`.
.. warning::
Changing the list of allowed headers may have unintended security
implications.
"""
HEADER_WHITELIST = (
"Authorization",
"Expires",
"Cookie",
)
TAG = namespaces.xep0363_http_upload, "put"
url = aioxmpp.xso.Attr("url")
headers = aioxmpp.xso.ChildValueMultiMap(
HeaderType
)
def xso_after_load(self):
whitelist = self.HEADER_WHITELIST
headers = list(self.headers.items())
self.headers.clear()
for key, value in headers:
if key not in whitelist:
continue
value = value.replace("\n", "")
self.headers.add(key, value)
class Get(aioxmpp.xso.XSO):
"""
.. attribute:: url
:annotation: : str
The URL at which the file can be retrieved after uploading.
"""
TAG = namespaces.xep0363_http_upload, "get"
url = aioxmpp.xso.Attr("url")
@aioxmpp.IQ.as_payload_class
class Slot(aioxmpp.xso.XSO):
"""
XSO representing the an upload slot provided by the server.
.. attribute:: get
Information about the GET request for the slot as :class:`.Get` XSO.
.. attribute:: put
Information about the PUT request for the slot as :class:`.Put` XSO.
"""
TAG = namespaces.xep0363_http_upload, "slot"
put = aioxmpp.xso.Child([Put])
get = aioxmpp.xso.Child([Get])
def validate(self):
super().validate()
if self.put is None:
raise ValueError("missing PUT information")
if self.get is None:
raise ValueError("missing GET information")
aioxmpp/i18n.py 0000664 0000000 0000000 00000030307 14160146213 0013665 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: i18n.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.i18n` -- Helper functions for localizing text
############################################################
This module provides facilities to facilitate the internationalization of
applications using :mod:`aioxmpp`.
.. autoclass:: LocalizingFormatter
.. autoclass:: LocalizableString
Shorthand functions
===================
.. autofunction:: _
.. autofunction:: ngettext
"""
import numbers
import string
from datetime import datetime, timedelta, date, time
import babel
import babel.dates
import babel.numbers
import tzlocal
class LocalizingFormatter(string.Formatter):
"""
This is an alternative implementation on top of
:class:`string.Formatter`. It is designed to work well with :mod:`babel`,
which also means that some things work differently when compared with the
default :class:`string.Formatter`.
Most notably, all objects from :mod:`datetime` are handled without using
their :meth:`__format__` method. Depending on their type, they are
forwarded to the respective formatting method in :mod:`babel.dates`.
+----------------------------+------------------------------------+-----------------+
|Type |Babel function |Timezone support |
+============================+====================================+=================+
|:class:`~datetime.datetime` |:func:`babel.dates.format_datetime` |yes |
+----------------------------+------------------------------------+-----------------+
|:class:`~datetime.timedelta`|:func:`babel.dates.format_timedelta`|no |
+----------------------------+------------------------------------+-----------------+
|:class:`~datetime.date` |:func:`babel.dates.format_date` |no |
+----------------------------+------------------------------------+-----------------+
|:class:`~datetime.time` |:func:`babel.dates.format_time` |no |
+----------------------------+------------------------------------+-----------------+
If the format specification is empty, the default format as babel defines
it is used.
In addition to date and time formatting, numbers which use the ``n`` format
type are also formatted with babel. If the format specification is empty
(except for the trailing ``n``), :func:`babel.numbers.format_number` is
used. Otherwise, the remainder of the format specification is passed as
format to :func:`babel.numbers.format_decimal`.
Examples::
>>> import pytz, babel, datetime, aioxmpp.i18n
>>> tz = pytz.timezone("Europe/Berlin")
>>> dt = datetime.datetime(year=2015, 5, 5, 15, 55, 55, tzinfo=tz)
>>> fmt = aioxmpp.i18n.LocalizingFormatter(locale=babel.Locale("en_GB"))
>>> fmt.format("{}", dt)
'5 May 2015 15:55:55'
>>> fmt.format("{:full}", dt)
'Tuesday, 5 May 2015 15:55:55 GMT+00:00'
>>> fmt.format("{:##.###n}, 120.3)
>>> fmt.format("{:##.###n}", 12.3)
'12.3'
>>> fmt.format("{:##.###;-(#)n}", -1.234)
'-(1.234)'
>>> fmt.format("{:n}", -10000)
'-10,000'
""" # NOQA
def __init__(self, locale=None, tzinfo=None):
super().__init__()
self.locale = locale if locale is not None else babel.default_locale()
self.tzinfo = tzinfo if tzinfo is not None else tzlocal.get_localzone()
def format_field(self, value, format_spec, locale=None, tzinfo=None):
if tzinfo is None:
tzinfo = self.tzinfo
if locale is None:
locale = self.locale
if isinstance(value, datetime):
if value.tzinfo is not None:
value = tzinfo.normalize(value)
if format_spec:
return babel.dates.format_datetime(value,
locale=locale,
format=format_spec)
else:
return babel.dates.format_datetime(value,
locale=locale)
elif isinstance(value, timedelta):
if format_spec:
return babel.dates.format_timedelta(value,
locale=locale,
format=format_spec)
else:
return babel.dates.format_timedelta(value,
locale=locale)
elif isinstance(value, date):
if format_spec:
return babel.dates.format_date(value,
locale=locale,
format=format_spec)
else:
return babel.dates.format_date(value,
locale=locale)
elif isinstance(value, time):
if format_spec:
return babel.dates.format_time(value,
locale=locale,
format=format_spec)
else:
return babel.dates.format_time(value,
locale=locale)
elif isinstance(value, numbers.Real) and format_spec.endswith("n"):
if len(format_spec) > 1:
return babel.numbers.format_decimal(value,
format=format_spec[:-1],
locale=locale)
else:
return babel.numbers.format_number(value, locale=locale)
else:
return super().format_field(value, format_spec)
def convert_field(self, value, conversion, locale=None, tzinfo=None):
if conversion != "s":
return super().convert_field(value, conversion)
if locale is None:
locale = self.locale
if tzinfo is None:
tzinfo = self.tzinfo
if isinstance(value, datetime):
return babel.dates.format_datetime(
tzinfo.normalize(value),
locale=locale)
elif isinstance(value, timedelta):
return babel.dates.format_timedelta(value, locale=locale)
elif isinstance(value, date):
return babel.dates.format_date(value, locale=locale)
elif isinstance(value, time):
return babel.dates.format_time(
value,
locale=locale)
return super().convert_field(value, conversion)
class LocalizableString:
"""
This class can be used for lazily translated localizable strings.
`singular` must be a :class:`str`. If `plural` is not set, the string will
be localized using `gettext`; otherwise, `ngettext` will be used. The
detailed process on localizing a string is described in the documentation
of :meth:`localize`.
Localizable strings compare equal if their `singular`, `plural` and
`number_index` values all match. The :func:`str` of a localizable string is
its singular string. The :func:`repr` depends on whether `plural` is set
and refers to the usage of :func:`_` and :func:`ngettext`.
The arguments are stored in attributes named like the
arguments. :class:`LocalizableString` instances are immutable and
hashable.
Examples::
>>> import aioxmpp.i18n, pytz, babel, gettext
>>> fmt = aioxmpp.i18n.LocalizingFormatter()
>>> translator = gettext.NullTranslations()
>>> s1 = aioxmpp.i18n.LocalizableString(
... "{count} thing",
... "{count} things", "count")
>>> s1.localize(fmt, translator, count=1)
'1 thing'
>>> s1.localize(fmt, translator, count=10)
'10 things'
.. automethod:: localize
"""
__slots__ = ("_singular", "_plural", "_number_index")
def __init__(self, singular, plural=None, number_index=None):
if plural is None and number_index is not None:
raise ValueError("plural is required if number_index is given")
self._singular = singular
self._plural = plural
if plural is not None:
if number_index is None:
number_index = "0"
self._number_index = str(number_index)
else:
self._number_index = None
@property
def singular(self):
return self._singular
@property
def plural(self):
return self._plural
@property
def number_index(self):
return self._number_index
def __eq__(self, other):
if not isinstance(other, LocalizableString):
return NotImplemented
return (self.singular == other.singular and
self.plural == other.plural and
self.number_index == other.number_index)
def __ne__(self, other):
return not (self == other)
def __hash__(self):
return hash((self._singular, self._plural, self._number_index))
def localize(self, formatter, translator, *args, **kwargs):
"""
Localize and format the string using the given `formatter` and
`translator`. The remaining args are passed to the
:meth:`~LocalizingFormatter.format` method of the `formatter`.
The `translator` must be an object supporting the
:class:`gettext.NullTranslations` interface.
If :attr:`plural` is not :data:`None`, the number which will be passed
to the `ngettext` method of `translator` is first extracted from the
`args` or `kwargs`, depending on :attr:`number_index`. The whole
semantics of all three are described in
:meth:`string.Formatter.get_field`, which is used by this method
(:attr:`number_index` is passed as `field_name`).
The value returned by :meth:`~string.Formatter.get_field` is then used
as third argument to `ngettext`, while the others are sourced from
:attr:`singular` and :attr:`plural`.
If :attr:`plural` is :data:`None`, the `gettext` method of `translator`
is used with :attr:`singular` as its only argument.
After the translation step, the `formatter` is used with the translated
string and `args` and `kwargs` to obtain a formatted version of the
string which is then returned.
All of this works best when using a :class:`LocalizingFormatter`.
"""
if self.plural is not None:
n, _ = formatter.get_field(self.number_index, args, kwargs)
translated = translator.ngettext(self.singular,
self.plural,
n)
else:
translated = translator.gettext(self.singular)
return formatter.vformat(translated, args, kwargs)
def __str__(self):
return self.singular
def __repr__(self):
if self.plural is not None:
return "ngettext({!r}, {!r}, {!r})".format(
self.singular,
self.plural,
self.number_index
)
return "_({!r})".format(self.singular)
def _(s):
"""
Return a new singular :class:`LocalizableString` using `s` as singular
form.
"""
return LocalizableString(s)
def ngettext(singular, plural, number_index):
"""
Return a new plural :class:`LocalizableString` with the given arguments;
these are passed to the constructor of :class:`LocalizableString`.
"""
return LocalizableString(singular, plural, number_index)
aioxmpp/ibb/ 0000775 0000000 0000000 00000000000 14160146213 0013265 5 ustar 00root root 0000000 0000000 aioxmpp/ibb/__init__.py 0000664 0000000 0000000 00000003130 14160146213 0015373 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.ibb` --- In-Band Bytestreams (:xep:`0047`)
#########################################################
This subpackage provides support for in-band bytestreams. The
bytestreams are exposed as instances of :class:`asyncio.Transport`,
which allows to speak any protocol implemented as
:class:`asyncio.Protocol` over them.
.. autoclass:: IBBService
.. autoclass:: IBBStanzaType
.. currentmodule:: aioxmpp.ibb.service
.. autoclass:: IBBTransport()
For serializing and deserializing data payloads carried by
:class:`~aioxmpp.Message` stanzas, a descriptor is added to them:
.. attribute:: aioxmpp.Message.xep0047_data
"""
from .xso import IBBStanzaType # NOQA
from .service import IBBService # NOQA
# import aioxmpp.ibb.service
aioxmpp/ibb/service.py 0000664 0000000 0000000 00000042346 14160146213 0015310 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import random
from datetime import timedelta
import aioxmpp
import aioxmpp.callbacks
import aioxmpp.errors as errors
import aioxmpp.service as service
import aioxmpp.utils as utils
from . import xso as ibb_xso
MAX_BLOCK_SIZE = (1 << 16) - 1
class IBBTransport(asyncio.Transport):
"""
The transport for IBB sessions.
.. note:: Never instantiate this class directly, all instances of
this class are created the methods
:meth:`~aioxmpp.ibb.IBBService.open_session` and
:meth:`~aioxmpp.ibb.IBBService.expect_session` of
:class:`~aioxmpp.ibb.IBBService`.
The following keys are supported for
:meth:`~asyncio.BaseTransport.get_extra_info`:
`block_size`
The maximal block size of data in a IBB stanza.
`peer_jid`
The JID of the peer.
`sid`
The session id of the unerlying IBB session.
`stanza_type`
The used stanza type.
"""
def __init__(self, service, peer_jid, sid, stanza_type, block_size):
self._protocol = None
self._service = service
self._stanza_type = stanza_type
self._sid = sid
self._peer_jid = peer_jid
self._block_size = block_size
self._incoming_seq = 0
self._outgoing_seq = 0
self._closed = False
self._closing = False
self.set_write_buffer_limits()
self._write_buffer = b""
self._can_write = asyncio.Event()
self._reading_paused = False
self._input_buffer = []
self._write_task = asyncio.ensure_future(self._write_task_main())
self._write_task.add_done_callback(
self._handle_close
)
self._wait_time = self._service.initial_wait_time.total_seconds()
self._retries = 0
def set_write_buffer_limits(self, high=None, low=None):
if low is None:
low = 4 * self._block_size
if high is None:
high = 8 * self._block_size
if low < 0:
raise ValueError("the limits must be positive")
if high < 0:
raise ValueError("the limits must be positive")
if low > high:
low = high
self._output_buffer_limit_low = low
self._output_buffer_limit_high = high
def get_write_buffer_limits(self):
return self._output_buffer_limit_low, self._output_buffer_limit_high
def get_write_buffer_size(self):
return len(self._write_buffer)
def set_protocol(self, proto):
self._protocol = proto
proto.connection_made(self)
def get_protocol(self):
return self._protocol
def pause_reading(self):
self._reading_paused = True
def resume_reading(self):
self._reading_paused = False
self._protocol.data_received(b"".join(self._input_buffer))
self._input_buffer.clear()
def is_closing(self):
return self._closing or self._closed
def get_extra_info(self, key, default=None):
return {
"block_size": self._block_size,
"peer_jid": self._peer_jid,
"stanza_type": self._stanza_type,
"sid": self._sid,
}.get(key, default)
async def _write_task_main(self):
e = None
while True:
await self._can_write.wait()
if self._write_buffer:
data = self._write_buffer[:self._block_size]
if self._stanza_type == ibb_xso.IBBStanzaType.IQ:
stanza = aioxmpp.IQ(
aioxmpp.IQType.SET,
to=self._peer_jid,
payload=ibb_xso.Data(
self._sid,
self._outgoing_seq,
data
)
)
elif self._stanza_type == ibb_xso.IBBStanzaType.MESSAGE:
# TODO: use some form of tracking for messages
stanza = aioxmpp.Message(
aioxmpp.MessageType.NORMAL,
to=self._peer_jid,
)
stanza.xep0047_data = ibb_xso.Data(
self._sid,
self._outgoing_seq,
data
)
try:
await self._service.client.send(
stanza
)
except errors.XMPPWaitError:
# wait and try again unless max retries have been reached
if self._retries < self._service.max_retries:
await asyncio.sleep(self._wait_time)
self._wait_time *= self._service.wait_backoff_factor
self._retries += 1
continue
else:
e = asyncio.TimeoutError()
break
except errors.StanzaError as _e:
# break the loop to close the connection
e = _e
break
# update the internal state after the successful
# write: remove the written data from the buffer and
# increment the sequence number
self._write_buffer = self._write_buffer[len(data):]
self._outgoing_seq += 1
self._outgoing_seq &= 0xffff
# reset the wait time
self._wait_time = \
self._service.initial_wait_time.total_seconds()
self._retries = 0
if len(self._write_buffer) < self._output_buffer_limit_low:
self._protocol.resume_writing()
if not self._write_buffer:
if self._closing:
e = None
break
self._can_write.clear()
close = ibb_xso.Close()
close.sid = self._sid
stanza = aioxmpp.IQ(
aioxmpp.IQType.SET,
to=self._peer_jid,
payload=close,
)
try:
await self._service.client.send(stanza)
except errors.StanzaError as _e:
if e is None:
e = _e
finally:
if e is not None:
raise e
def write(self, data):
"""
Send `data` over the IBB. If `data` is larger than the block size
is is chunked and sent in chunks.
Chunks from one call of :meth:`write` will always be sent in
series.
"""
if self.is_closing():
return
self._write_buffer += data
if len(self._write_buffer) >= self._output_buffer_limit_high:
self._protocol.pause_writing()
if self._write_buffer:
self._can_write.set()
def _connection_closed(self):
self._write_task.cancel()
def _handle_close(self, fut):
e = None
self._service._remove_session(self._peer_jid, self._sid)
try:
e = fut.exception()
except asyncio.CancelledError:
pass
self._protocol.connection_lost(e)
self._closed = True
def close(self):
"""
Close the session.
"""
if self.is_closing():
return
self._closing = True
# make sure the writer wakes up
self._can_write.set()
def abort(self):
"""
Abort the session.
"""
if self.is_closing():
return
self._connection_closed()
def _data_received(self, data):
if self._closed:
return
if self._reading_paused:
self._input_buffer.append(data)
else:
self._protocol.data_received(data)
def _process_iq(self, payload):
if payload.seq != self._incoming_seq:
raise errors.XMPPCancelError(
condition=errors.ErrorCondition.UNEXPECTED_REQUEST
)
self._incoming_seq += 1
self._incoming_seq &= 0xffff
self._data_received(payload.content)
def _process_msg(self, payload):
if payload.seq != self._incoming_seq:
return
self._incoming_seq += 1
self._incoming_seq &= 0xffff
self._data_received(payload.content)
class IBBService(service.Service):
"""
A service implementing in-band bytestreams.
Methods for establishing sessions:
.. automethod:: expect_session
.. automethod:: open_session
The following attributes control the establishment of sessions due
to a received request, that was not announced to the service by
:meth:`expect_session`:
.. attribute:: session_limit
:annotation: = 0
The maximal number of sessions to be accepted. If there are
that many or more active sessions, no new sessions are
accepted, unless they are whitelisted by
:meth:`expect_session`. (This means, that by default only
expected sessions are accepted!).
.. attribute:: default_protocol_factory
The protocol factory to be used when an unexpected connection
is established. This *must* be set when changing
:attr:`session_limit` to a non-zero value.
.. signal:: on_session_accepted(transport, protocol)
Fires when a session is established due to a received open
request that was not expected (compare
:meth:`expect_session`). This can only happen when
:attr:`session_limit` is set to another value than its default
value.
The following attributes control how the IBB sessions react to
errors of type wait:
.. attribute:: max_retries
:annotation: = 5
The number of times it is tried to resend a data stanza, when a
:class:`~aioxmpp.errors.XMPPWaitError` is received. When
:attr:`max_retries` have been tried, the session is closed.
`connection_lost` of the protocol receives an
:class:`asyncio.TimeoutError`.
.. attribute:: initial_wait_time
:annotation: = timedelta(seconds=1)
The time to wait when receiving a
:class:`~aioxmpp.errors.XMPPWaitError` for the first time.
.. attribute:: wait_backoff_factor
:annotation: = 1.2
The factor by which the wait time is prolonged on each
successive wait error.
"""
on_session_accepted = aioxmpp.callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._sessions = {}
self.session_limit = 0
self._expected_sessions = {}
self.default_protocol_factory = None
self.client.on_stream_destroyed.connect(
self._on_stream_destroyed
)
self.max_retries = 5
self.initial_wait_time = timedelta(seconds=1)
self.wait_backoff_factor = 1.2
def _on_stream_destroyed(self):
self._expected_sessions = {}
# tear down the remaining open sessions
for session in list(self._sessions.values()):
session.abort()
def expect_session(self, protocol_factory, peer_jid, sid):
"""
Whitelist the session with `peer_jid` and the session id `sid` and
return it when it is established. This is meant to be used
with signalling protocols like Jingle and is the counterpart
to :meth:`open_session`.
:returns: an awaitable object, whose result is the tuple
`(transport, protocol)`
"""
def on_done(fut):
del self._expected_sessions[sid, peer_jid]
_, fut = self._expected_sessions[sid, peer_jid] = (
protocol_factory, asyncio.Future()
)
fut.add_done_callback(on_done)
return fut
async def open_session(self, protocol_factory, peer_jid, *,
stanza_type=ibb_xso.IBBStanzaType.IQ,
block_size=4096, sid=None):
"""
Establish an in-band bytestream session with `peer_jid` and
return the transport and protocol.
:param protocol_factory: the protocol factory
:type protocol_factory: a nullary callable returning an
:class:`asyncio.Protocol` instance
:param peer_jid: the JID with which to establish the byte-stream.
:type peer_jid: :class:`aioxmpp.JID`
:param stanza_type: the stanza type to use
:type stanza_type: class:`~aioxmpp.ibb.IBBStanzaType`
:param block_size: the maximal size of blocks to transfer
:type block_size: :class:`int`
:param sid: the session id to use
:type sid: :class:`str` (must be a valid NMTOKEN)
:returns: the transport and protocol
:rtype: a tuple of :class:`aioxmpp.ibb.service.IBBTransport`
and :class:`asyncio.Protocol`
"""
if block_size > MAX_BLOCK_SIZE:
raise ValueError("block_size too large")
if sid is None:
sid = utils.to_nmtoken(random.getrandbits(8*8))
open_ = ibb_xso.Open()
open_.stanza = stanza_type
open_.sid = sid
open_.block_size = block_size
# XXX: retry on XMPPModifyError with RESOURCE_CONSTRAINT
await self.client.send(
aioxmpp.IQ(
aioxmpp.IQType.SET,
to=peer_jid,
payload=open_,
)
)
handle = self._sessions[sid, peer_jid] = IBBTransport(
self,
peer_jid,
sid,
stanza_type,
block_size,
)
protocol = protocol_factory()
handle.set_protocol(protocol)
return handle, protocol
@service.iq_handler(
aioxmpp.IQType.SET,
ibb_xso.Open)
async def _handle_open_request(self, iq):
peer_jid = iq.from_
sid = iq.payload.sid
block_size = iq.payload.block_size
stanza_type = iq.payload.stanza
if block_size > MAX_BLOCK_SIZE:
raise errors.XMPPModifyError(
condition=errors.ErrorCondition.RESOURCE_CONSTRAINT
)
try:
protocol_factory, expected_future = \
self._expected_sessions[sid, peer_jid]
except KeyError:
if len(self._sessions) >= self.session_limit:
raise errors.XMPPCancelError(
condition=errors.ErrorCondition.NOT_ACCEPTABLE
)
expected_future = None
protocol_factory = self.default_protocol_factory
if (sid, peer_jid) in self._sessions:
# disallow opening a session twice
if expected_future is not None:
# is this correct?
expected_future.cancel()
raise errors.XMPPCancelError(
condition=errors.ErrorCondition.NOT_ACCEPTABLE
)
handle = self._sessions[sid, peer_jid] = IBBTransport(
self,
peer_jid,
sid,
stanza_type,
block_size
)
protocol = protocol_factory()
handle.set_protocol(protocol)
if expected_future is None:
self.on_session_accepted((handle, protocol))
else:
expected_future.set_result((handle, protocol))
@service.iq_handler(
aioxmpp.IQType.SET,
ibb_xso.Close)
async def _handle_close_request(self, iq):
peer_jid = iq.from_
sid = iq.payload.sid
try:
session_handle = self._sessions[sid, peer_jid]
except KeyError:
raise errors.XMPPCancelError(
condition=errors.ErrorCondition.ITEM_NOT_FOUND
)
session_handle._connection_closed()
@service.iq_handler(
aioxmpp.IQType.SET,
ibb_xso.Data)
async def _handle_data(self, iq):
peer_jid = iq.from_
sid = iq.payload.sid
try:
session_handle = self._sessions[sid, peer_jid]
except KeyError:
raise errors.XMPPCancelError(
condition=errors.ErrorCondition.ITEM_NOT_FOUND
)
session_handle._process_iq(iq.payload)
@service.inbound_message_filter
def _handle_message(self, msg):
if msg.xep0047_data is None:
return msg
payload = msg.xep0047_data
peer_jid = msg.from_
sid = payload.sid
try:
session = self._sessions[sid, peer_jid]
except KeyError:
return None
session._process_msg(payload)
return None
def _remove_session(self, peer_jid, sid):
del self._sessions[sid, peer_jid]
aioxmpp/ibb/xso.py 0000664 0000000 0000000 00000005132 14160146213 0014451 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import enum
import aioxmpp.xso as xso
import aioxmpp.stanza as stanza
from aioxmpp.utils import namespaces
namespaces.xep0047 = "http://jabber.org/protocol/ibb"
class IBBStanzaType(enum.Enum):
"""
Enumeration of the the two stanza types supported by IBB for
transporting data.
.. attribute:: IQ
Send the in-band bytestream data using IQ stanzas. This is
recommended and default. The reply mechanism of IQ allows
tracking the connectivitiy and implements basic rate limiting,
since we wait for the reply to the previous message before
sending a new one.
.. attribute:: MESSAGE
Send the in-band bytestream data using Message stanzas. This is
not recommended since lost packages due to intermittent
connectivity failures will not be obvious.
"""
IQ = "iq"
MESSAGE = "message"
@stanza.IQ.as_payload_class
class Open(xso.XSO):
TAG = (namespaces.xep0047, "open")
block_size = xso.Attr("block-size", type_=xso.Integer())
# XXX: sid should be restricted to NMTOKEN
sid = xso.Attr("sid", type_=xso.String())
stanza = xso.Attr(
"stanza",
type_=xso.EnumCDataType(IBBStanzaType),
default=IBBStanzaType.IQ,
)
@stanza.IQ.as_payload_class
class Close(xso.XSO):
TAG = (namespaces.xep0047, "close")
sid = xso.Attr("sid", type_=xso.String())
@stanza.IQ.as_payload_class
class Data(xso.XSO):
TAG = (namespaces.xep0047, "data")
seq = xso.Attr("seq", type_=xso.Integer())
sid = xso.Attr("sid", type_=xso.String())
content = xso.Text(type_=xso.Base64Binary())
def __init__(self, sid, seq, content):
self.seq = seq
self.sid = sid
self.content = content
stanza.Message.xep0047_data = xso.Child([Data])
aioxmpp/ibr/ 0000775 0000000 0000000 00000000000 14160146213 0013305 5 ustar 00root root 0000000 0000000 aioxmpp/ibr/__init__.py 0000664 0000000 0000000 00000003251 14160146213 0015417 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.ibr` --- In-Band Registration (:xep:`0077`)
##########################################################
This module implements in-band registration.
Registration Functions
======================
The module level registration functions work on an
:class:`aioxmpp.protocol.XMLStream` and before authentication. They
allow to register a new account with a server.
.. autofunction:: get_registration_fields
.. autofunction:: register
Helper Function
===============
.. autofunction:: get_used_fields
Service
=======
.. autoclass:: RegistrationService
XSO Definitions
===============
.. autoclass:: Query
"""
from .service import ( # NOQA: F401
RegistrationService,
get_registration_fields,
register,
)
from .service import get_used_fields # NOQA: F401
from .xso import Query # NOQA: F401
aioxmpp/ibr/service.py 0000664 0000000 0000000 00000012320 14160146213 0015315 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import aioxmpp
import logging
from aioxmpp.service import Service
from . import xso
logger = logging.getLogger(__name__)
async def get_registration_fields(xmlstream, timeout=60):
"""
A query is sent to the server to obtain the fields that need to be
filled to register with the server.
:param xmlstream: Specifies the stream connected to the server where
the account will be created.
:type xmlstream: :class:`aioxmpp.protocol.XMLStream`
:param timeout: Maximum time in seconds to wait for an IQ response, or
:data:`None` to disable the timeout.
:type timeout: :class:`~numbers.Real` or :data:`None`
:return: :attr:`list`
"""
iq = aioxmpp.IQ(
to=aioxmpp.JID.fromstr(xmlstream._to),
type_=aioxmpp.IQType.GET,
payload=xso.Query()
)
iq.autoset_id()
reply = await aioxmpp.protocol.send_and_wait_for(
xmlstream,
[iq],
[aioxmpp.IQ],
timeout=timeout
)
return reply.payload
async def register(xmlstream, query_xso, timeout=60):
"""
Create a new account on the server.
:param query_xso: XSO with the information needed for the registration.
:type query_xso: :class:`~aioxmpp.ibr.Query`
:param xmlstream: Specifies the stream connected to the server where
the account will be created.
:type xmlstream: :class:`aioxmpp.protocol.XMLStream`
:param timeout: Maximum time in seconds to wait for an IQ response, or
:data:`None` to disable the timeout.
:type timeout: :class:`~numbers.Real` or :data:`None`
"""
iq = aioxmpp.IQ(
to=aioxmpp.JID.fromstr(xmlstream._to),
type_=aioxmpp.IQType.SET,
payload=query_xso
)
iq.autoset_id()
await aioxmpp.protocol.send_and_wait_for(
xmlstream,
[iq],
[aioxmpp.IQ],
timeout=timeout
)
def get_used_fields(payload):
"""
Get a list containing the names of the fields that are used in the
xso.Query.
:param payload: Query object o be
:type payload: :class:`~aioxmpp.ibr.Query`
:return: :attr:`list`
"""
return [
tag
for tag, descriptor in payload.CHILD_MAP.items()
if descriptor.__get__(payload, type(payload)) is not None
]
class RegistrationService(Service):
"""
Service implementing the XMPP In-Band Registration(:xep:`0077`)
use cases for registered entities.
This service allows an already registered and authenticated entity
to request information about the registration, cancel an existing
registration, or change a password.
.. automethod:: get_client_info
.. automethod:: change_pass
.. automethod:: cancel_registration
"""
async def get_client_info(self):
"""
A query is sent to the server to obtain the client's data stored at the
server.
:return: :class:`~aioxmpp.ibr.Query`
"""
iq = aioxmpp.IQ(
to=self.client.local_jid.bare().replace(localpart=None),
type_=aioxmpp.IQType.GET,
payload=xso.Query()
)
reply = await self.client.send(iq)
return reply
async def change_pass(self, new_pass):
"""
Change the client password for 'new_pass'.
:param new_pass: New password of the client.
:type new_pass: :class:`str`
:param old_pass: Old password of the client.
:type old_pass: :class:`str`
"""
iq = aioxmpp.IQ(
to=self.client.local_jid.bare().replace(localpart=None),
type_=aioxmpp.IQType.SET,
payload=xso.Query(self.client.local_jid.localpart, new_pass)
)
await self.client.send(iq)
async def cancel_registration(self):
"""
Cancels the currents client's account with the server.
Even if the cancellation is successful, this method will raise an
exception due to he account no longer exists for the server, so the
client will fail.
To continue with the execution, this method should be surrounded by a
try/except statement.
"""
iq = aioxmpp.IQ(
to=self.client.local_jid.bare().replace(localpart=None),
type_=aioxmpp.IQType.SET,
payload=xso.Query()
)
iq.payload.remove = True
await self.client.send(iq)
aioxmpp/ibr/xso.py 0000664 0000000 0000000 00000010557 14160146213 0014500 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0077_in_band = "jabber:iq:register"
@aioxmpp.IQ.as_payload_class
class Query(xso.XSO):
"""
:xep:`077` In-Band Registraion query :class:`~aioxmpp.xso.XSO`.
It has the following fields described in the XEP document:
.. attribute:: username
.. attribute:: nick
.. attribute:: password
.. attribute:: name
.. attribute:: first
.. attribute:: last
.. attribute:: email
.. attribute:: address
.. attribute:: city
.. attribute:: state
.. attribute:: zip
.. attribute:: phone
.. attribute:: url
.. attribute:: date
.. attribute:: misc
.. attribute:: text
.. attribute:: key
.. attribute:: registered
.. attribute:: remove
"""
TAG = (namespaces.xep0077_in_band, "query")
username = xso.ChildText(
(namespaces.xep0077_in_band, "username"),
default=None,
)
instructions = xso.ChildText(
(namespaces.xep0077_in_band, "instructions"),
default=None,
)
nick = xso.ChildText(
(namespaces.xep0077_in_band, "nick"),
default=None,
)
password = xso.ChildText(
(namespaces.xep0077_in_band, "password"),
default=None,
)
name = xso.ChildText(
(namespaces.xep0077_in_band, "name"),
default=None,
)
first = xso.ChildText(
(namespaces.xep0077_in_band, "first"),
default=None,
)
last = xso.ChildText(
(namespaces.xep0077_in_band, "last"),
default=None,
)
email = xso.ChildText(
(namespaces.xep0077_in_band, "email"),
default=None,
)
address = xso.ChildText(
(namespaces.xep0077_in_band, "address"),
default=None,
)
city = xso.ChildText(
(namespaces.xep0077_in_band, "city"),
default=None,
)
state = xso.ChildText(
(namespaces.xep0077_in_band, "state"),
default=None,
)
zip = xso.ChildText(
(namespaces.xep0077_in_band, "zip"),
default=None,
)
phone = xso.ChildText(
(namespaces.xep0077_in_band, "phone"),
default=None,
)
url = xso.ChildText(
(namespaces.xep0077_in_band, "url"),
default=None,
)
date = xso.ChildText(
(namespaces.xep0077_in_band, "date"),
default=None,
)
misc = xso.ChildText(
(namespaces.xep0077_in_band, "misc"),
default=None,
)
text = xso.ChildText(
(namespaces.xep0077_in_band, "text"),
default=None,
)
key = xso.ChildText(
(namespaces.xep0077_in_band, "key"),
default=None,
)
registered = xso.ChildFlag(
(namespaces.xep0077_in_band, "registered")
)
remove = xso.ChildFlag(
(namespaces.xep0077_in_band, "remove")
)
def __init__(self, username=None, password=None, aux_fields=None):
"""
Get an xso.Query object with the info provided in he parameters.
:param username: Username of the query
:type username: :class:`str`
:param password: Password of the query.
:type password: :class:`str`
:param aux_fields: Auxiliary fields in case additional info is needed.
:type aux_fields: :class:`dict`
:return: :class:`xso.Query`
"""
self.username = username
self.password = password
if aux_fields is not None:
for key, value in aux_fields.items():
setattr(self, key, value)
aioxmpp/im/ 0000775 0000000 0000000 00000000000 14160146213 0013136 5 ustar 00root root 0000000 0000000 aioxmpp/im/__init__.py 0000664 0000000 0000000 00000006454 14160146213 0015260 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.im` --- Instant Messaging Utilities and Services
###############################################################
This subpackage provides tools for Instant Messaging applications based on
XMPP. The tools are meant to be useful for both user-facing as well as
automated IM applications.
.. warning::
:mod:`aioxmpp.im` is highly experimental, even more than :mod:`aioxmpp` by
itself is. This is not a threat, this is a chance. Please play with the
API, try to build an application around it, and let us know how it feels!
This is your chance to work with us on the API.
On the other hand, yes, there is a risk that we’ll restructure the API
massively in the next release, even though it works quite well for our
applications currently.
Terminology
===========
This is a short overview of the terminology. The full definitions can be found
in the glossary and are linked.
:term:`Conversation`
Communication context for two or more parties.
:term:`Conversation Member`
An entity taking part in a :term:`Conversation`.
:term:`Conversation Implementation`
A :term:`Service` which provides means to create and manage specific
:class:`~.AbstractConversation` subclasses.
:term:`Service Member`
A :term:`Conversation Member` which represents the service over which the
conversation is run inside the conversation.
.. module:: aioxmpp.im.p2p
:mod:`.im.p2p` --- One-on-one conversations
===========================================
.. autoclass:: Service
.. autoclass:: Conversation
.. autoclass:: Member
.. currentmodule:: aioxmpp.im
:mod:`aioxmpp.muc` --- Multi-User-Chats (:xep:`45`)
===================================================
.. seealso::
:mod:`aioxmpp.muc`
has a :term:`Conversation Implementation` for MUCs.
Core Services
=============
.. autoclass:: ConversationService
Enumerations
============
.. autoclass:: ConversationState
.. autoclass:: ConversationFeature
.. autoclass:: InviteMode
Abstract base classes
=====================
.. module:: aioxmpp.im.conversation
.. currentmodule:: aioxmpp.im.conversation
Conversations
-------------
.. autoclass:: AbstractConversation
.. autoclass:: AbstractConversationMember
Conversation Service
--------------------
.. autoclass:: AbstractConversationService
"""
from .conversation import ( # NOQA: F401
ConversationState,
ConversationFeature,
InviteMode,
)
from .service import ( # NOQA: F401
ConversationService,
)
aioxmpp/im/body.py 0000664 0000000 0000000 00000002543 14160146213 0014451 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: body.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
# .
#
########################################################################
import functools
import aioxmpp.structs
@functools.singledispatch
def set_message_body(x, message):
raise NotImplementedError(
"type {!r} is not supported as message body".format(
type(x)
)
)
@set_message_body.register(str)
def set_message_body_str(x, message):
message.body.clear()
message.body[None] = x
@set_message_body.register(aioxmpp.structs.LanguageMap)
def set_message_body_langmap(x, message):
message.body.clear()
message.body.update(x)
aioxmpp/im/conversation.py 0000664 0000000 0000000 00000103573 14160146213 0016233 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: conversation.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
# .
#
########################################################################
import abc
import asyncio
import enum
import aioxmpp.callbacks
class InviteMode(enum.Enum):
"""
Represent different possible modes for sending an invitation.
.. attribute:: DIRECT
The invitation is sent directly to the invitee, without going through a
service specific to the conversation.
.. attribute:: MEDIATED
The invitation is sent indirectly through a service which is providing
the conversation. Advantages of using this mode include most notably
that the service can automatically add the invitee to the list of
allowed participants in configurations where such restrictions exist (or
deny the request if the inviter does not have the permissions to do so).
"""
DIRECT = 0
MEDIATED = 1
class ConversationFeature(enum.Enum):
"""
Represent individual features of a :term:`Conversation` of a
:term:`Conversation Implementation`.
.. seealso::
The :attr:`.AbstractConversation.features` provides a set of features
offered by a specific :term:`Conversation`.
.. attribute:: BAN
Allows use of :meth:`~.AbstractConversation.ban`.
.. attribute:: BAN_WITH_KICK
Explicit support for setting the `request_kick` argument to :data:`True`
in :meth:`~.AbstractConversation.ban`.
.. attribute:: INVITE
Allows use of :meth:`~.AbstractConversation.invite`.
.. attribute:: INVITE_DIRECT
Explicit support for the :attr:`~.InviteMode.DIRECT` invite mode when
calling :meth:`~.AbstractConversation.invite`.
.. attribute:: INVITE_DIRECT_CONFIGURE
Explicit support for configuring the conversation to allow the invitee
to join when using :attr:`~.InviteMode.DIRECT` with
:meth:`~.AbstractConversation.invite`.
.. attribute:: INVITE_MEDIATED
Explicit support for the :attr:`~.InviteMode.MEDIATED` invite mode when
calling :meth:`~.AbstractConversation.invite`.
.. attribute:: INVITE_UPGRADE
Explicit support and requirement for `allow_upgrade` when
calling :meth:`~.AbstractConversation.invite`.
.. attribute:: KICK
Allows use of :meth:`~.AbstractConversation.kick`.
.. attribute:: LEAVE
Allows use of :meth:`~.AbstractConversation.leave`.
.. attribute:: SEND_MESSAGE
Allows use of :meth:`~.AbstractConversation.send_message`.
.. attribute:: SEND_MESSAGE_TRACKED
Allows use of :meth:`~.AbstractConversation.send_message_tracked`.
.. attribute:: SET_NICK
Allows use of :meth:`~.AbstractConversation.set_nick`.
.. attribute:: SET_NICK_OF_OTHERS
Explicit support for changing the nickname of other members when calling
:meth:`~.AbstractConversation.set_nick`.
.. attribute:: SET_TOPIC
Allows use of :meth:`~.AbstractConversation.set_topic`.
"""
BAN = 'ban'
BAN_WITH_KICK = 'ban-with-kick'
INVITE = 'invite'
INVITE_DIRECT = 'invite-direct'
INVITE_DIRECT_CONFIGURE = 'invite-direct-configure'
INVITE_MEDIATED = 'invite-mediated'
INVITE_UPGRADE = 'invite-upgrade'
KICK = 'kick'
LEAVE = 'leave'
SEND_MESSAGE = 'send-message'
SEND_MESSAGE_TRACKED = 'send-message-tracked'
SET_TOPIC = 'set-topic'
SET_NICK = 'set-nick'
SET_NICK_OF_OTHERS = 'set-nick-of-others'
class ConversationState(enum.Enum):
"""
State of a conversation.
.. note::
The members of this enumeration closely mirror the states of :xep:`85`,
with the addition of the internal :attr:`PENDING` state. The reason is
that :xep:`85` is a Final Standard and is state-of-the-art for
conversation states in XMPP.
.. attribute:: PENDING
The conversation has been created, possibly automatically, and the
application has not yet set the conversation state.
.. attribute:: ACTIVE
.. epigraph::
User accepts an initial content message, sends a content message,
gives focus to the chat session interface (perhaps after being
inactive), or is otherwise paying attention to the conversation.
-- from :xep:`85`
.. attribute:: INACTIVE
.. epigraph::
User has not interacted with the chat session interface for an
intermediate period of time (e.g., 2 minutes).
-- from :xep:`85`
.. attribute:: GONE
.. epigraph::
User has not interacted with the chat session interface, system, or
device for a relatively long period of time (e.g., 10 minutes).
-- from :xep:`85`
.. attribute:: COMPOSING
.. epigraph::
User is actively interacting with a message input interface specific
to this chat session (e.g., by typing in the input area of a chat
window).
-- from :xep:`85`
.. attribute:: PAUSED
.. epigraph::
User was composing but has not interacted with the message input
interface for a short period of time (e.g., 30 seconds).
-- from :xep:`85`
When any of the above states is entered, a notification is sent out to the
participants of the conversation.
"""
PENDING = 0
ACTIVE = 1
INACTIVE = 2
GONE = 3
COMPOSING = 4
PAUSED = 5
class AbstractConversationMember(metaclass=abc.ABCMeta):
"""
Represent a member in a :class:`~.AbstractConversation`.
While all :term:`implementations ` will have
their own additional attributes, the following attributes must exist on
all subclasses:
.. autoattribute:: conversation_jid
.. autoattribute:: direct_jid
.. autoattribute:: is_self
.. autoattribute:: uid
"""
def __init__(self,
conversation_jid,
is_self):
super().__init__()
self._conversation_jid = conversation_jid
self._is_self = is_self
@property
def direct_jid(self):
"""
If available, this is the :class:`~aioxmpp.JID` address of the member
for direct contact, outside of the conversation. It is independent of
the conversation itself.
If not available, this attribute reads as :data:`None`.
"""
return None
@property
def conversation_jid(self):
"""
The :class:`~aioxmpp.JID` of the conversation member relative to the
conversation.
"""
return self._conversation_jid
@property
def is_self(self):
"""
True if the member refers to ourselves in the conversation, false
otherwise.
"""
return self._is_self
@abc.abstractproperty
def uid(self) -> bytes:
"""
This is a unique ID for the occupant. It can be used across sessions
and restarts to assert equality between occupants. It is guaranteed
to be equal if and only if the entity is the same (up to a uncertainty
caused by the limited length of the unique ID, somewhere in the order
of ``2**(-120)``).
The identifier is always a :class:`bytes` and **must** be treated as
opaque by users. The only guarantee which is given is that its length
will be less than 4096 bytes.
"""
class AbstractConversation(metaclass=abc.ABCMeta):
"""
Interface for a conversation.
.. note::
All signals may receive additional keyword arguments depending on the
specific subclass implementing them. Handlers connected to the signals
**must** support arbitrary keyword arguments.
To support future extensions to the base specification, subclasses must
prefix all keyword argument names with a common, short prefix which ends
with an underscore. For example, a MUC implementation could use
``muc_presence``.
Future extensions to the base class will use either names without
underscores or the ``base_`` prefix.
.. note::
In the same spirit, methods defined on subclasses should use the same
prefix. However, the base class does not guarantee that it won’t use
names with underscores in future extensions.
To prevent collisions, subclasses should avoid the use of prefixes which
are verbs in the english language.
Signals:
.. note::
The `member` argument common to many signals is never :data:`None` and
always an instance of a subclass of
:class:`~.AbstractConversationMember`. However, the `member` may not be
part of the :attr:`members` of the conversation. For example, it may be
the :attr:`service_member` object which is never part of
:attr:`members`. Other cases where a non-member is passed as `member`
may exist depending on the conversation subclass.
.. signal:: on_message(msg, member, source, tracker=None, **kwargs)
A message occurred in the conversation.
:param msg: Message which was received.
:type msg: :class:`aioxmpp.Message`
:param member: The member object of the sender.
:type member: :class:`.AbstractConversationMember`
:param source: How the message was acquired
:type source: :class:`~.MessageSource`
:param tracker: A message tracker which tracks an outbound message.
:type tracker: :class:`aioxmpp.tracking.MessageTracker`
This signal is emitted on the following events:
* A message was sent to the conversation and delivered directly to us.
This is the classic case of "a message was received". In this case,
`source` is :attr:`~.MessageSource.STREAM` and `member` is the
:class:`~.AbstractConversationMember` of the originator.
* A message was sent from this client. This is the classic case of "a
message was sent". In this case, `source` is
:attr:`~.MessageSource.STREAM` and `member` refers to ourselves.
* A carbon-copy of a message received by another resource of our account
which belongs to this conversation was received. `source` is
:attr:`~.MessageSource.CARBONS` and `member` is the
:class:`~.AbstractConversationMember` of the originator.
* A carbon-copy of a message sent by another resource of our account was
sent to this conversation. In this case, `source` is
:attr:`~.MessageSource.CARBONS` and `member` refers to ourselves.
Often, you don’t need to distinguish between carbon-copied and
non-carbon-copied messages.
All messages which are not handled otherwise (and for example dispatched
as :meth:`on_state_changed` signals) are dispatched to this event. This
may include messages not understood and/or which carry no textual
payload.
`tracker` is set only for messages sent by the local member. If a
message is sent from the client without tracking, `tracker` is
:data:`None`; otherwise, the `tracker` is always set, even for messages
sent by other clients. It depends on the conversation implementation as
well as timing in which state a tracker is at the time the event is
emitted.
.. signal:: on_state_changed(member, new_state, msg, **kwargs)
The conversation state of a member has changed.
:param member: The member object of the member whose state changed.
:type member: :class:`.AbstractConversationMember`
:param new_state: The new conversation state of the member.
:type new_state: :class:`~.ConversationState`
:param msg: The stanza which conveyed the state change.
:type msg: :class:`aioxmpp.Message`
This signal also fires for state changes of the local occupant. The
exact point at which this signal fires for the local occupant is
determined by the implementation.
.. signal:: on_presence_changed(member, resource, presence, **kwargs)
The presence state of a member has changed.
:param member: The member object of the affected member.
:type member: :class:`~.AbstractConversationMember`
:param resource: The resource of the member which changed presence.
:type resource: :class:`str` or :data:`None`
:param presence: The presence stanza
:type presence: :class:`aioxmpp.Presence`
If the `presence` stanza affects multiple resources, `resource` holds
the affected resource and the event is emitted once per affected
resource.
However, the `presence` stanza affects only a single resource,
`resource` is :data:`None`; the affected resource can be extracted from
the :attr:`~.StanzaBase.from_` of the `presence` stanza in that case.
This is to help implementations to know whether a bunch of resources was
shot offline by a single presence (`resource` is not :data:`None`), e.g.
due to an error or whether a single resource went offline by itself.
Implementations may want to only show the former case.
.. note::
In some implementations, unavailable presence implies that a
participant leaves the room, in which case :meth:`on_leave` is
emitted instead.
.. signal:: on_nick_changed(member, old_nick, new_nick, **kwargs)
The nickname of a member has changed
:param member: The member object of the member whose nick has changed.
:type member: :class:`~.AbstractConversationMember`
:param old_nick: The old nickname of the member.
:type old_nick: :class:`str` or :data:`None`
:param new_nick: The new nickname of the member.
:type new_nick: :class:`str`
The new nickname is already set in the `member` object, if the `member`
object has an accessor for the nickname.
In some cases, `old_nick` may be :data:`None`. These cases include those
where it is not trivial for the protocol to actually determine the old
nickname or where no nickname was set before.
.. signal:: on_topic_changed(member, new_topic, **kwargs)
The topic of the conversation has changed.
:param member: The member object who changed the topic.
:type member: :class:`~.AbstractConversationMember`
:param new_topic: The new topic of the conversation.
:type new_topic: :class:`.LanguageMap`
.. signal:: on_uid_changed(member, old_uid, **kwargs)
This rare signal notifies that the
:attr:`~.AbstractConversationMember.uid` of a member has changed.
:param member: The member object for which the UID has changed.
:type member: :class:`~.AbstractConversationMember`
:param old_uid: The old UID of the member.
:type old_uid: :class:`bytes`
The new uid is already available at the members
:attr:`~.AbstractConversationMember.uid` attribute.
This signal can only fire for multi-user conversations where the
visibility of identifying information changes. In many cases, it will
be irrelevant for the application, but for some use-cases it might be
important to be able to re-write historical messages to use the new
uid.
.. signal:: on_enter()
The conversation was entered.
This event is emitted up to once for a :class:`AbstractConversation`.
One of :meth:`on_enter` and :meth:`on_failure` is emitted exactly
once for each :class:`AbstractConversation` instance.
.. seealso::
:func:`aioxmpp.callbacks.first_signal` can be used nicely to await
the completion of entering a conversation::
conv = ... # let this be your conversation
await first_signal(conv.on_enter, conv.on_failure)
# await first_signal() will either return None (success) or
# raise the exception passed to :meth:`on_failure`.
.. note::
This and :meth:`on_failure` are the only signals which **must not**
receive keyword arguments, so that they continue to work with
:attr:`.AdHocSignal.AUTO_FUTURE` and
:func:`~.callbacks.first_signal`.
.. versionadded:: 0.10
.. signal:: on_failure(exc)
The conversation could not be entered.
:param exc: The exception which caused the operation to fail.
:type exc: :class:`Exception`
Often, `exc` will be a :class:`aioxmpp.errors.XMPPError` indicating
an error emitted from an involved server, such as permission problems,
conflicts or non-existent peers.
This signal can only be emitted instead of :meth:`on_enter` and not
after the room has been entered. If the conversation is terminated
due to a remote cause at a later point, :meth:`on_exit` is used.
One of :meth:`on_enter` and :meth:`on_failure` is emitted exactly
once for each :class:`AbstractConversation` instance.
.. note::
This and :meth:`on_failure` are the only signals which **must not**
receive keyword arguments, so that they continue to work with
:attr:`.AdHocSignal.AUTO_FUTURE` and
:func:`~.callbacks.first_signal`.
.. versionadded:: 0.10
.. signal:: on_join(member, **kwargs)
A new member has joined the conversation.
:param member: The member object of the new member.
:type member: :class:`~.AbstractConversationMember`
When this signal is called, the `member` is already included in the
:attr:`members`.
.. signal:: on_leave(member, **kwargs)
A member has left the conversation.
:param member: The member object of the previous member.
:type member: :class:`~.AbstractConversationMember`
When this signal is called, the `member` has already been removed from
the :attr:`members`.
.. signal:: on_exit(**kwargs)
The local user has left the conversation.
When this signal fires, the conversation is defunct in the sense that it
cannot be used to send messages anymore. A new conversation needs to be
started.
Properties:
.. autoattribute:: features
.. autoattribute:: jid
.. autoattribute:: members
.. autoattribute:: me
.. autoattribute:: service_member
Methods:
.. note::
See :attr:`features` for discovery of support for individual methods at
a given conversation instance.
.. automethod:: ban
.. automethod:: invite
.. automethod:: kick
.. automethod:: leave
.. automethod:: send_message
.. automethod:: send_message_tracked
.. automethod:: set_nick
.. automethod:: set_topic
Interface solely for subclasses:
.. attribute:: _client
The `client` as passed to the constructor.
"""
on_message = aioxmpp.callbacks.Signal()
on_state_changed = aioxmpp.callbacks.Signal()
on_presence_changed = aioxmpp.callbacks.Signal()
on_join = aioxmpp.callbacks.Signal()
on_leave = aioxmpp.callbacks.Signal()
on_exit = aioxmpp.callbacks.Signal()
on_failed = aioxmpp.callbacks.Signal()
on_nick_changed = aioxmpp.callbacks.Signal()
on_enter = aioxmpp.callbacks.Signal()
on_failure = aioxmpp.callbacks.Signal()
on_topic_changed = aioxmpp.callbacks.Signal()
def __init__(self, service, parent=None, **kwargs):
super().__init__(**kwargs)
self._service = service
self._client = service.client
self.__parent = parent
def _not_implemented_error(self, what):
return NotImplementedError(
"{} not supported for this type of conversation".format(what)
)
@property
def parent(self):
"""
The conversation to which this conversation belongs. Read-only.
When the parent is closed, the sub-conversations are also closed.
"""
return self.__parent
@abc.abstractproperty
def members(self):
"""
An iterable of members of this conversation.
"""
@abc.abstractproperty
def me(self):
"""
The member representing the local member.
"""
@abc.abstractproperty
def jid(self):
"""
The address of the conversation.
"""
@property
def service_member(self):
"""
The member representing the service on which the conversation is
hosted, if available.
This is never included in :attr:`members`. It may be used as member
argument in events to make it clear that the message originates from
the service and not an unknown occupant.
This may be :data:`None`.
.. versionadded:: 0.10
"""
@property
def features(self):
"""
A set of features supported by this :term:`Conversation`.
The members of the set are usually drawn from the
:class:`~.ConversationFeature` :mod:`enumeration `;
:term:`Conversation Implementations ` are
free to add custom elements from other enumerations to this set.
Unless stated otherwise, the methods of :class:`~.AbstractConversation`
and its subclasses always may throw one of the following exceptions,
**unless** support for those methods is explicitly stated with an
appropriate :class:`~.ConversationFeature` member in the
:attr:`features`.
* :class:`NotImplementedError` if the :term:`Conversation
Implementation` does not support the method at all.
* :class:`RuntimeError` if the server does not support the method.
* :class:`aioxmpp.XMPPCancelError` with ``feature-not-implemented``
condition.
*If* support for the method is claimed in :attr:`features`, these
exceptions **must not** be raised (for the given reason; of course, a
method may still raise an :class:`aioxmpp.XMPPCancelError` due for
other conditions such as ``item-not-found``).
"""
return frozenset()
def send_message(self, body):
"""
Send a message to the conversation.
:param msg: The message to send.
:type msg: :class:`aioxmpp.Message`
:return: The stanza token obtained from sending.
:rtype: :class:`~aioxmpp.stream.StanzaToken`
The default implementation simply calls :meth:`send_message_tracked`
and immediately cancels the tracking object, returning only the stanza
token.
There is no need to provide proper address attributes on `msg`.
Implementations will override those attributes with the values
appropriate for the conversation. Some implementations may allow the
user to choose a :attr:`~aioxmpp.Message.type_`, but others may simply
stamp it over.
Subclasses may override this method with a more specialised
implementation. Subclasses which do not provide tracked message sending
**must** override this method to provide untracked message sending.
.. seealso::
The corresponding feature is
:attr:`.ConversationFeature.SEND_MESSAGE`. See :attr:`features` for
details.
"""
token, tracker = self.send_message_tracked(body)
tracker.cancel()
return token
@abc.abstractmethod
def send_message_tracked(self, msg, *, timeout=None):
"""
Send a message to the conversation with tracking.
:param msg: The message to send.
:type msg: :class:`aioxmpp.Message`
:param timeout: Timeout for the tracking.
:type timeout: :class:`numbers.RealNumber`, :class:`datetime.timedelta`
or :data:`None`
:raise NotImplementedError: if tracking is not implemented
:return: The stanza token obtained from sending and the
:class:`aioxmpp.tracking.MessageTracker` tracking the delivery.
:rtype: :class:`~aioxmpp.stream.StanzaToken`,
:class:`~aioxmpp.tracking.MessageTracker`
There is no need to provide proper address attributes on `msg`.
Implementations will override those attributes with the values
appropriate for the conversation. Some implementations may allow the
user to choose a :attr:`~aioxmpp.Message.type_`, but others may simply
stamp it over.
Tracking may not be supported by all implementations, and the degree of
support varies with implementation. Please check the documentation
of the respective subclass.
`timeout` is the number of seconds (or a :class:`datetime.timedelta`
object which defines the timespan) after which the tracking expires and
is closed if no response has been received in the mean time. If
`timeout` is set to :data:`None`, the tracking never expires.
.. warning::
Read :ref:`api-tracking-memory`.
.. seealso::
The corresponding feature is
:attr:`.ConversationFeature.SEND_MESSAGE_TRACKED`. See
:attr:`features` for details.
"""
async def kick(self, member, reason=None):
"""
Kick a member from the conversation.
:param member: The member to kick.
:param reason: A reason to show to the members of the conversation
including the kicked member.
:type reason: :class:`str`
:raises aioxmpp.errors.XMPPError: if the server returned an error for
the kick command.
.. seealso::
The corresponding feature is
:attr:`.ConversationFeature.KICK`. See :attr:`features` for details.
"""
raise self._not_implemented_error("kicking members")
async def ban(self, member, reason=None, *, request_kick=True):
"""
Ban a member from re-joining the conversation.
:param member: The member to ban.
:param reason: A reason to show to the members of the conversation
including the banned member.
:type reason: :class:`str`
:param request_kick: A flag indicating that the member should be
removed from the conversation immediately, too.
:type request_kick: :class:`bool`
If `request_kick` is true, the implementation attempts to kick the
member from the conversation, too, if that does not happen
automatically. There is no guarantee that the member is not removed
from the conversation even if `request_kick` is false.
Additional features:
:attr:`~.ConversationFeature.BAN_WITH_KICK`
If `request_kick` is true, the member is kicked from the
conversation.
.. seealso::
The corresponding feature for this method is
:attr:`.ConversationFeature.BAN`. See :attr:`features` for details
on the semantics of features.
"""
raise self._not_implemented_error("banning members")
async def invite(self, address, text=None, *,
mode=InviteMode.DIRECT,
allow_upgrade=False):
"""
Invite another entity to the conversation.
:param address: The address of the entity to invite.
:type address: :class:`aioxmpp.JID`
:param text: A reason/accompanying text for the invitation.
:param mode: The invitation mode to use.
:type mode: :class:`~.im.InviteMode`
:param allow_upgrade: Whether to allow creating a new conversation to
satisfy the invitation.
:type allow_upgrade: :class:`bool`
:raises NotImplementedError: if the requested `mode` is not supported
:raises ValueError: if `allow_upgrade` is false, but a new conversation
is required.
:return: The stanza token for the invitation and the possibly new
conversation object
:rtype: tuple of :class:`~.StanzaToken` and
:class:`~.AbstractConversation`
.. note::
Even though this is a coroutine, it returns a stanza token. The
coroutine-ness may be needed to generate the invitation in the
first place. Sending the actual invitation is done non-blockingly
and the stanza token for that is returned. To wait until the
invitation has been sent, unpack the stanza token from the result
and await it.
Return the new conversation object to use. In many cases, this will
simply be the current conversation object, but in some cases (e.g. when
someone is invited to a one-on-one conversation), a new conversation
must be created and used.
If `allow_upgrade` is false and a new conversation would be needed to
invite an entity, :class:`ValueError` is raised.
Additional features:
:attr:`~.ConversationFeature.INVITE_DIRECT`
Support for :attr:`~.im.InviteMode.DIRECT` mode.
:attr:`~.ConversationFeature.INVITE_DIRECT_CONFIGURE`
If a direct invitation is used, the conversation will be configured
to allow the invitee to join before the invitation is sent. This may
fail with a :class:`aioxmpp.errors.XMPPError`, in which case the
error is re-raised and the invitation not sent.
:attr:`~.ConversationFeature.INVITE_MEDIATED`
Support for :attr:`~.im.InviteMode.MEDIATED` mode.
:attr:`~.ConversationFeature.INVITE_UPGRADE`
If `allow_upgrade` is :data:`True`, an upgrade will be performed and
a new conversation is returned. If `allow_upgrade` is :data:`False`,
the invite will fail.
.. seealso::
The corresponding feature for this method is
:attr:`.ConversationFeature.INVITE`. See :attr:`features` for
details on the semantics of features.
"""
raise self._not_implemented_error("inviting entities")
async def set_nick(self, new_nickname):
"""
Change our nickname.
:param new_nickname: The new nickname for the member.
:type new_nickname: :class:`str`
:raises ValueError: if the nickname is not a valid nickname
Sends the request to change the nickname and waits for the request to
be sent.
There is no guarantee that the nickname change will actually be
applied; listen to the :meth:`on_nick_changed` event.
Implementations may provide a different method which provides more
feedback.
.. seealso::
The corresponding feature for this method is
:attr:`.ConversationFeature.SET_NICK`. See :attr:`features` for
details on the semantics of features.
"""
raise self._not_implemented_error("changing the nickname")
async def set_topic(self, new_topic):
"""
Change the (possibly publicly) visible topic of the conversation.
:param new_topic: The new topic for the conversation.
:type new_topic: :class:`str`
Sends the request to change the topic and waits for the request to
be sent.
There is no guarantee that the topic change will actually be
applied; listen to the :meth:`on_topic_chagned` event.
Implementations may provide a different method which provides more
feedback.
.. seealso::
The corresponding feature for this method is
:attr:`.ConversationFeature.SET_TOPIC`. See :attr:`features` for
details on the semantics of features.
"""
raise self._not_implemented_error("changing the topic")
async def leave(self):
"""
Leave the conversation.
.. seealso::
The corresponding feature is
:attr:`.ConversationFeature.LEAVE`. See :attr:`features` for
details.
"""
class AbstractConversationService(metaclass=abc.ABCMeta):
"""
Abstract base class for
:term:`Conversation Services `.
Useful implementations:
.. autosummary::
aioxmpp.im.p2p.Service
aioxmpp.muc.MUCClient
In general, conversation services should provide a method (*not* a
coroutine method) to start a conversation using the service. That method
should return the fresh :class:`~.AbstractConversation` object immediately
and start possibly needed background tasks to actually initiate the
conversation. The caller should use the
:meth:`~.AbstractConversation.on_enter` and
:meth:`~.AbstractConversation.on_failure` signals to be notified of the
result of the join operation.
Signals:
.. signal:: on_conversation_new(conversation)
Fires when a new conversation is created in the service.
:param conversation: The new conversation.
:type conversation: :class:`AbstractConversation`
.. seealso::
:meth:`.ConversationService.on_conversation_added`
is a signal shared among all :term:`Conversation
Implementations ` which gets
emitted whenever a new conversation is added. If you need all
conversations, that is the signal to listen for.
.. signal:: on_spontaneous_conversation(conversation)
Like :meth:`on_conversation_new`, but is only emitted for conversations
which are created without local interaction.
:param conversation: The new conversation.
:type conversation: :class:`AbstractConversation`
.. versionadded:: 0.10
"""
on_conversation_new = aioxmpp.callbacks.Signal()
on_spontaneous_conversation = aioxmpp.callbacks.Signal()
aioxmpp/im/dispatcher.py 0000664 0000000 0000000 00000011720 14160146213 0015637 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import enum
import aioxmpp.callbacks
import aioxmpp.carbons
import aioxmpp.service
import aioxmpp.stream
class MessageSource(enum.Enum):
STREAM = 0
CARBONS = 1
class IMDispatcher(aioxmpp.service.Service):
"""
Dispatches messages, taking into account carbons.
.. function:: message_filter(message, peer, sent, source)
A message was received or sent.
:param message: Message stanza
:type message: :class:`aioxmpp.Message`
:param peer: The peer from/to which the stanza was received/sent
:type peer: :class:`aioxmpp.JID`
:param sent: Whether the mesasge was sent or received.
:type sent: :class:`bool`
:param source: The source of the message.
:type source: :class:`MessageSource`
`message` is the message stanza which was sent or received.
`peer` is the JID of the peer involved in the message. If the message
was sent, this is the :attr:`~.StanzaBase.to` and otherwise it is the
:attr:`~.StanzaBase.from_` attribute of the stanza.
If `sent` is true, the message was sent from this resource *or* another
resource of the same account, if Message Carbons are enabled.
`source` indicates how the message was sent or received. It may be one
of the values of the :class:`MessageSource` enumeration.
"""
ORDER_AFTER = [
# we want to be loaded after the SimplePresenceDispatcher to ensure
# that PresenceClient has updated its data structures before the
# dispatch_presence handler runs.
# this helps one-to-one conversations a lot, because they can simply
# re-use the PresenceClient state
aioxmpp.dispatcher.SimplePresenceDispatcher,
aioxmpp.carbons.CarbonsClient,
]
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self.message_filter = aioxmpp.callbacks.Filter()
self.presence_filter = aioxmpp.callbacks.Filter()
@aioxmpp.service.depsignal(
aioxmpp.node.Client,
"before_stream_established")
async def enable_carbons(self, *args):
carbons = self.dependencies[aioxmpp.carbons.CarbonsClient]
try:
await carbons.enable()
except (RuntimeError, aioxmpp.errors.XMPPError):
self.logger.info(
"remote server does not support message carbons"
)
else:
self.logger.info(
"message carbons enabled successfully"
)
return True
@aioxmpp.service.depsignal(
aioxmpp.stream.StanzaStream,
"on_message_received")
def dispatch_message(self, message, *,
sent=False,
source=MessageSource.STREAM):
if message.xep0280_received is not None:
if (message.from_ is not None and
message.from_ != self.client.local_jid.bare()):
return
message = message.xep0280_received.stanza
source = MessageSource.CARBONS
elif message.xep0280_sent is not None:
if (message.from_ is not None and
message.from_ != self.client.local_jid.bare()):
return
message = message.xep0280_sent.stanza
sent = True
source = MessageSource.CARBONS
peer = message.to if sent else message.from_
filtered = self.message_filter.filter(
message,
peer,
sent,
source,
)
if filtered is not None:
self.logger.debug(
"message was not processed by any IM handler: %s",
filtered,
)
@aioxmpp.service.depsignal(
aioxmpp.stream.StanzaStream,
"on_presence_received")
def dispatch_presence(self, presence, *, sent=False):
filtered = self.presence_filter.filter(
presence,
presence.from_,
sent,
)
if filtered is not None:
self.logger.debug(
"presence was not processed by any IM handler: %s",
filtered,
)
aioxmpp/im/muc.py 0000664 0000000 0000000 00000001666 14160146213 0014305 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: muc.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
# .
#
########################################################################
import aioxmpp.service
class MUCIMService(aioxmpp.service.Service):
pass
aioxmpp/im/p2p.py 0000664 0000000 0000000 00000015626 14160146213 0014223 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: p2p.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
# .
#
########################################################################
import asyncio
import aioxmpp.service
from .conversation import (
AbstractConversationMember,
AbstractConversation,
AbstractConversationService,
ConversationFeature,
)
from .dispatcher import IMDispatcher, MessageSource
from .service import ConversationService
class Member(AbstractConversationMember):
"""
Member of a one-on-one conversation.
.. autoattribute:: direct_jid
"""
def __init__(self, peer_jid, is_self):
super().__init__(peer_jid, is_self)
@property
def direct_jid(self):
"""
The JID of the peer.
"""
return self._conversation_jid
@property
def uid(self) -> bytes:
return b"xmpp:" + str(self._conversation_jid.bare()).encode("utf-8")
class Conversation(AbstractConversation):
"""
Implementation of :class:`~.im.conversation.AbstractConversation` for
one-on-one conversations.
.. seealso::
:class:`.im.conversation.AbstractConversation`
for documentation on the interface implemented by this class.
"""
def __init__(self, service, peer_jid, parent=None):
super().__init__(service, parent=parent)
self.__peer_jid = peer_jid
self.__members = (
Member(self._client.local_jid, True),
Member(peer_jid, False),
)
@property
def features(self):
return (
frozenset([ConversationFeature.SEND_MESSAGE,
ConversationFeature.LEAVE]) |
super().features
)
def _handle_message(self, msg, peer, sent, source):
if sent:
member = self.__members[0]
else:
member = self.__members[1]
self._service.logger.debug("emitting on_message for %s",
self.__peer_jid)
self.on_message(msg, member, source)
@property
def jid(self):
return self.__peer_jid
@property
def members(self):
return self.__members
@property
def me(self):
return self.__members[0]
def send_message(self, msg):
msg.autoset_id()
msg.to = self.__peer_jid
self.on_message(msg, self.me, MessageSource.STREAM)
return self._client.enqueue(msg)
async def send_message_tracked(self, msg):
raise self._not_implemented_error("message tracking")
async def leave(self):
self._service._conversation_left(self)
class Service(AbstractConversationService, aioxmpp.service.Service):
"""
Manage one-to-one conversations.
.. seealso::
:class:`~.AbstractConversationService`
for useful common signals
This service manages one-to-one conversations, including private
conversations running in the framework of a multi-user chat. In those
cases, the respective multi-user chat conversation service requests a
conversation from this service to use.
For each bare JID, there can either be a single conversation for the bare
JID or zero or more conversations for full JIDs. Mixing conversations to
bare and full JIDs of the same bare JID is not allowed, because it is
ambiguous.
This service creates conversations if it detects them as one-on-one
conversations. Subscribe to
:meth:`aioxmpp.im.ConversationService.on_conversation_added` to be notified
about new conversations being auto-created.
.. automethod:: get_conversation
"""
ORDER_AFTER = [
ConversationService,
IMDispatcher,
]
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._conversationmap = {}
self.on_conversation_new.connect(
self.dependencies[ConversationService]._add_conversation
)
def _make_conversation(self, peer_jid, spontaneous):
self.logger.debug("creating new conversation for %s (spontaneous=%s)",
peer_jid, spontaneous)
result = Conversation(self, peer_jid, parent=None)
self._conversationmap[peer_jid] = result
if spontaneous:
self.on_spontaneous_conversation(result)
self.on_conversation_new(result)
result.on_enter()
self.logger.debug("new conversation for %s set up and events emitted",
peer_jid)
return result
@aioxmpp.service.depfilter(IMDispatcher, "message_filter")
def _filter_message(self, msg, peer, sent, source):
try:
existing = self._conversationmap[peer]
except KeyError:
try:
existing = self._conversationmap[peer.bare()]
except KeyError:
existing = None
if (existing is None and
(msg.type_ == aioxmpp.MessageType.CHAT or
msg.type_ == aioxmpp.MessageType.NORMAL) and
msg.body):
conversation_jid = peer.bare()
if msg.xep0045_muc_user is not None:
conversation_jid = peer
existing = self._make_conversation(conversation_jid, True)
if existing is not None:
existing._handle_message(msg, peer, sent, source)
return None
return msg
def get_conversation(self, peer_jid, *, current_jid=None):
"""
Get or create a new one-to-one conversation with a peer.
:param peer_jid: The JID of the peer to converse with.
:type peer_jid: :class:`aioxmpp.JID`
:param current_jid: The current JID to lock the conversation to (see
:rfc:`6121`).
:type current_jid: :class:`aioxmpp.JID`
:rtype: :class:`Conversation`
:return: The new or existing conversation with the peer.
`peer_jid` must be a full or bare JID. See the :class:`Service`
documentation for details.
.. versionchanged:: 0.10
In 0.9, this was a coroutine. Sorry.
"""
try:
return self._conversationmap[peer_jid]
except KeyError:
pass
return self._make_conversation(peer_jid, False)
def _conversation_left(self, conv):
del self._conversationmap[conv.jid]
aioxmpp/im/service.py 0000664 0000000 0000000 00000011765 14160146213 0015162 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import functools
import aioxmpp.callbacks
import aioxmpp.service
class ConversationService(aioxmpp.service.Service):
"""
Central place where all :class:`.im.conversation.AbstractConversation`
subclass instances are collected.
It provides discoverability of all existing conversations (in no particular
order) and signals on addition and removal of active conversations. This is
useful for front ends to track conversations globally without needing to
know about the specific conversation providers.
.. signal:: on_conversation_added(conversation)
A new conversation has been added.
:param conversation: The conversation which was added.
:type conversation: :class:`~.im.conversation.AbstractConversation`
This signal is fired when a new conversation is added by a
:term:`Conversation Implementation`.
.. note::
If you are looking for a "on_conversation_removed" event or similar,
there is none. You should use the
:meth:`.AbstractConversation.on_exit` event of the `conversation`.
.. signal:: on_message(conversation,
*args, **kwargs)
Emits whenever any active conversation emits its
:meth:`~.im.Conversation.on_message` event. The arguments are forwarded
1:1, with the :class:`~.im.AbstractConversation` instance pre-pended to
the argument list.
.. autoattribute:: conversations
.. automethod:: get_conversation
For :term:`Conversation Implementations `, the
following methods are intended; they should not be used by applications.
.. automethod:: _add_conversation
"""
on_conversation_added = aioxmpp.callbacks.Signal()
on_message = aioxmpp.callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._conversation_meta = {}
self._conversation_map = {}
@property
def conversations(self):
"""
Return an iterable of conversations in which the local client is
participating.
"""
return self._conversation_meta.keys()
def _remove_conversation(self, conv):
del self._conversation_map[conv.jid]
tokens, = self._conversation_meta.pop(conv)
for signal, token in tokens:
signal.disconnect(token)
def _handle_conversation_exit(self, conv, *args, **kwargs):
self._remove_conversation(conv)
return False
def _add_conversation(self, conversation):
"""
Add the conversation and fire the :meth:`on_conversation_added` event.
:param conversation: The conversation object to add.
:type conversation: :class:`~.AbstractConversation`
The conversation is added to the internal list of conversations which
can be queried at :attr:`conversations`. The
:meth:`on_conversation_added` event is fired.
In addition, the :class:`ConversationService` subscribes to the
:meth:`~.AbstractConversation.on_exit` event to remove the conversation
from the list automatically. There is no need to remove a conversation
from the list explicitly.
"""
handler = functools.partial(
self._handle_conversation_exit,
conversation
)
tokens = []
def linked_token(signal, handler):
return signal, signal.connect(handler)
tokens.append(linked_token(conversation.on_exit, handler))
tokens.append(linked_token(conversation.on_failure, handler))
tokens.append(linked_token(conversation.on_message, functools.partial(
self.on_message,
conversation,
)))
self._conversation_meta[conversation] = (
tokens,
)
self._conversation_map[conversation.jid] = conversation
self.on_conversation_added(conversation)
def get_conversation(self, conversation_address):
"""
Return the :class:`.im.AbstractConversation` for a given JID.
:raises KeyError: if there is currently no matching conversation
"""
return self._conversation_map[conversation_address]
aioxmpp/mdr/ 0000775 0000000 0000000 00000000000 14160146213 0013313 5 ustar 00root root 0000000 0000000 aioxmpp/mdr/__init__.py 0000664 0000000 0000000 00000003327 14160146213 0015431 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.mdr` --- Message Delivery Reciepts (:xep:`184`)
##############################################################
This module provides a :mod:`aioxmpp.tracking` service which tracks :xep:`184`
replies to messages and accordingly updates attached
:class:`~aioxmpp.tracking.MessageTracker` objects.
.. versionadded:: 0.10
To make use of receipt tracking, :meth:`~aioxmpp.Client.summon` the
:class:`~aioxmpp.DeliveryReceiptsService` on your :class:`aioxmpp.Client` and
use the :meth:`~.DeliveryReceiptsService.attach_tracker` method.
To send delivery receipts, the :func:`aioxmpp.mdr.compose_receipt` helper
function is provided.
.. currentmodule:: aioxmpp
.. autoclass:: DeliveryReceiptsService
.. currentmodule:: aioxmpp.mdr
.. autofunction:: compose_receipt
"""
from .service import ( # NOQA: F401
DeliveryReceiptsService,
compose_receipt,
)
aioxmpp/mdr/service.py 0000664 0000000 0000000 00000011305 14160146213 0015325 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp.disco
import aioxmpp.service
import aioxmpp.tracking
from . import xso
class DeliveryReceiptsService(aioxmpp.service.Service):
"""
:term:`Tracking Service` which tracks :xep:`184` replies.
To send a tracked message, use the :meth:`attach_tracker` method before
sending.
.. automethod:: attach_tracker
"""
ORDER_AFTER = [aioxmpp.disco.DiscoServer]
disco_feature = aioxmpp.disco.register_feature("urn:xmpp:receipts")
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._bare_jid_maps = {}
@aioxmpp.service.inbound_message_filter
def _inbound_message_filter(self, stanza):
recvd = stanza.xep0184_received
if recvd is not None:
try:
tracker = self._bare_jid_maps.pop(
(stanza.from_, recvd.message_id)
)
except KeyError:
self.logger.debug(
"received unexpected/late/dup . dropping."
)
else:
try:
tracker._set_state(
aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT
)
except ValueError as exc:
self.logger.debug(
"failed to update tracker after receipt: %s",
exc,
)
return None
return stanza
def attach_tracker(self, stanza, tracker=None):
"""
Return a new tracker or modify one to track the stanza.
:param stanza: Stanza to track.
:type stanza: :class:`aioxmpp.Message`
:param tracker: Existing tracker to attach to.
:type tracker: :class:`.tracking.MessageTracker`
:raises ValueError: if the stanza is of type
:attr:`~aioxmpp.MessageType.ERROR`
:raises ValueError: if the stanza contains a delivery receipt
:return: The message tracker for the stanza.
:rtype: :class:`.tracking.MessageTracker`
The `stanza` gets a :xep:`184` receipt request attached and internal
handlers are set up to update the `tracker` state once a confirmation
is received.
.. warning::
See the :ref:`api-tracking-memory`.
"""
if stanza.xep0184_received is not None:
raise ValueError(
"requesting delivery receipts for delivery receipts is not "
"allowed"
)
if stanza.type_ == aioxmpp.MessageType.ERROR:
raise ValueError(
"requesting delivery receipts for errors is not supported"
)
if tracker is None:
tracker = aioxmpp.tracking.MessageTracker()
stanza.xep0184_request_receipt = True
stanza.autoset_id()
self._bare_jid_maps[stanza.to, stanza.id_] = tracker
return tracker
def compose_receipt(message):
"""
Compose a :xep:`184` delivery receipt for a :class:`~aioxmpp.Message`.
:param message: The message to compose the receipt for.
:type message: :class:`~aioxmpp.Message`
:raises ValueError: if the input message is of type
:attr:`~aioxmpp.MessageType.ERROR`
:raises ValueError: if the input message is a message receipt itself
:return: A message which serves as a receipt for the input message.
:rtype: :class:`~aioxmpp.Message`
"""
if message.type_ == aioxmpp.MessageType.ERROR:
raise ValueError("receipts cannot be generated for error messages")
if message.xep0184_received:
raise ValueError("receipts cannot be generated for receipts")
if message.id_ is None:
raise ValueError("receipts cannot be generated for id-less messages")
reply = message.make_reply()
reply.to = reply.to.bare()
reply.xep0184_received = xso.Received(message.id_)
return reply
aioxmpp/mdr/xso.py 0000664 0000000 0000000 00000002606 14160146213 0014502 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0184_receipts = "urn:xmpp:receipts"
aioxmpp.Message.xep0184_request_receipt = xso.ChildFlag(
(namespaces.xep0184_receipts, "request"),
)
class Received(xso.XSO):
TAG = namespaces.xep0184_receipts, "received"
message_id = xso.Attr(
"id",
)
def __init__(self, message_id):
super().__init__()
self.message_id = message_id
aioxmpp.Message.xep0184_received = xso.Child(
[
Received,
]
)
aioxmpp/misc/ 0000775 0000000 0000000 00000000000 14160146213 0013464 5 ustar 00root root 0000000 0000000 aioxmpp/misc/__init__.py 0000664 0000000 0000000 00000011226 14160146213 0015577 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.misc` -- Miscellaneous XSOs
##########################################
This subpackage bundles XSO definitions for several XEPs. They do not get their
own subpackage because they often only define one or two XSOs without any logic
involved. The XSOs are often intended for re-use by other protocols.
Out of Band Data (:xep:`66`)
============================
.. autoclass:: OOBExtension
.. attribute:: aioxmpp.Message.xep0066_oob
Delayed Delivery (:xep:`203`)
=============================
.. autoclass:: Delay()
.. attribute:: aioxmpp.Message.xep0203_delay
A :class:`Delay` instance which indicates that the message has been
delivered with delay.
Stanza Forwarding (:xep:`297`)
==============================
.. autoclass:: Forwarded()
Last Message Correction (:xep:`308`)
====================================
.. autoclass:: Replace()
.. attribute:: aioxmpp.Message.xep308_replace
A :class:`Replace` instance which indicates that the message is supposed
to replcae another message.
Chat Markers (:xep:`333`)
=========================
.. autoclass:: ReceivedMarker
.. autoclass:: DisplayedMarker
.. autoclass:: AcknowledgedMarker
.. attribute:: aioxmpp.Message.xep0333_marker
JSON Containers (:xep:`335`)
============================
:xep:`335` defines a standard way to transport JSON data in XMPP. The
:class:`JSONContainer` is an XSO class which represents the ```` element
specified in :xep:`335`.
:mod:`aioxmpp` also provides an :class:`~aioxmpp.xso.AbstractElementType`
called :class:`JSONContainerType` which can be used to extract JSON data from
an element using the :class:`JSONContainer` format.
.. autoclass:: JSONContainer
.. autoclass:: JSONContainerType
Unique and Stable Stanza IDs (:xep:`359`)
=========================================
:xep:`359` defines a way to attach additional IDs to a stanza, allowing
entities on the path from the sender to the recipient to signal under which ID
they know a specific stanza. This is most notably used by MAM (:xep:`313`).
.. autoclass:: StanzaID(*[, id_][, by])
.. autoclass:: OriginID()
.. attribute:: aioxmpp.Message.xep0359_stanza_ids
This is a mapping which associates the `by` value of a stanza ID with the
list of IDs (as strings or :data:`None` if the attribute was not set)
assigned by that entity. Normally, there should only ever be a single ID
assigned, but misbehaving parties on the path could inject IDs for other
entities.
To allow code handling the ID selection deterministically in such cases,
all IDs are exposed.
.. attribute:: aioxmpp.Message.xep0359_origin_id
The :class:`OriginID` object, if any.
Pre-Authenticated Roster Subcription (:xep:`379`)
=================================================
.. autoclass:: Preauth
.. attribute:: aioxmpp.Presence.xep0379_preauth
The pre-auth element associate with a subscription request.
Current Jabber OpenPGP Usage (:xep:`27`)
========================================
.. autoclass:: OpenPGPEncrypted
.. autoclass:: OpenPGPSigned
.. attribute:: aioxmpp.Message.xep0027_encrypted
Instance of :class:`OpenPGPEncrypted`, if present.
.. note::
:xep:`27` does not specify the signing of messages.
.. attribute:: aioxmpp.Presence.xep0027_signed
Instance of :class:`OpenPGPSigned`, if present.
"""
from .delay import Delay # NOQA: F401
from .lmc import Replace # NOQA: F401
from .forwarding import Forwarded # NOQA: F401
from .oob import OOBExtension # NOQA: F401
from .markers import ( # NOQA: F401
ReceivedMarker,
DisplayedMarker,
AcknowledgedMarker,
)
from .json import JSONContainer, JSONContainerType # NOQA: F401
from .pars import Preauth # NOQA: F401
from .openpgp_legacy import (
OpenPGPEncrypted,
OpenPGPSigned,
)
from .stanzaid import ( # NOQA: F401
StanzaID,
OriginID,
)
aioxmpp/misc/delay.py 0000664 0000000 0000000 00000003560 14160146213 0015140 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: delay.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
# .
#
########################################################################
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
from ..stanza import Message
namespaces.xep0203_delay = "urn:xmpp:delay"
class Delay(xso.XSO):
"""
A marker indicating delayed delivery of a stanza.
.. attribute:: from_
The address as :class:`aioxmpp.JID` of the entity where the stanza was
delayed. May be :data:`None`.
.. attribute:: stamp
The timestamp (as :class:`datetime.datetime`) at which the stanza was
originally sent or intended to be sent.
.. attribute:: reason
The reason for which the stanza was delayed or :data:`None`.
.. warning::
Please take the security considerations of :xep:`203` into account.
"""
TAG = namespaces.xep0203_delay, "delay"
from_ = xso.Attr(
"from",
type_=xso.JID(),
default=None,
)
stamp = xso.Attr(
"stamp",
type_=xso.DateTime(),
)
reason = xso.Text(
default=None
)
Message.xep0203_delay = xso.ChildList([Delay])
aioxmpp/misc/forwarding.py 0000664 0000000 0000000 00000003252 14160146213 0016202 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: forwarding.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
# .
#
########################################################################
import aioxmpp.xso as xso
from ..stanza import IQ, Message, Presence
from .delay import Delay
from aioxmpp.utils import namespaces
namespaces.xep0297_forward = "urn:xmpp:forward:0"
class Forwarded(xso.XSO):
"""
Wrap a stanza for forwarding.
.. attribute:: delay
If not :data:`None`, this is a :class:`aioxmpp.misc.Delay` XSO which
indicates the timestamp at which the wrapped stanza was originally sent.
.. attribute:: stanza
The forwarded stanza.
.. warning::
Please take the security considerations of :xep:`297` and the protocol
using this XSO into account.
"""
TAG = namespaces.xep0297_forward, "forwarded"
delay = xso.Child([Delay])
stanza = xso.Child(
[
Message,
IQ,
Presence,
]
)
aioxmpp/misc/json.py 0000664 0000000 0000000 00000004444 14160146213 0015015 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: json.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
# .
#
########################################################################
import aioxmpp.xso as xso
import aioxmpp.pubsub.xso
from aioxmpp.utils import namespaces
namespaces.xep0335_json = "urn:xmpp:json:0"
@aioxmpp.pubsub.xso.as_payload_class
class JSONContainer(xso.XSO):
"""
XSO which represents the JSON container specified in :xep:`335`.
This is a full XSO and not an attribute descriptor. It is registered as
pubsub payload by default.
"""
TAG = (namespaces.xep0335_json, "json")
json_data = xso.Text(
type_=xso.JSON(),
)
def __init__(self, json_data=None):
super().__init__()
self.json_data = json_data
class JSONContainerType(xso.AbstractElementType):
"""
XSO element type to unwrap JSON container payloads specified in :xep:`335`.
This type is designed to be used with the ChildValue* descriptors provided
in :mod:`aioxmpp.xso`, for example with :class:`aioxmpp.xso.ChildValue` or
:class:`aioxmpp.xso.ChildValueList`.
.. code:: python
class HTTPRESTMessage(aioxmpp.xso.XSO):
TAG = ("https://neverdothis.example", "http-rest")
method = aioxmpp.xso.Attr("method")
payload = aioxmpp.xso.ChildValue(
type_=aioxmpp.misc.JSONContainerType
)
"""
@classmethod
def get_xso_types(cls):
return [JSONContainer]
@classmethod
def unpack(cls, v):
return v.json_data
@classmethod
def pack(cls, v):
return JSONContainer(v)
aioxmpp/misc/lmc.py 0000664 0000000 0000000 00000002510 14160146213 0014607 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: lmc.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
# .
#
########################################################################
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
from ..stanza import Message
namespaces.xep0308_replace = "urn:xmpp:message-correct:0"
class Replace(xso.XSO):
"""
A marker indicating that the stanza is the correction of another one
.. attribute:: id_
The identifier of the stanza to correct.
"""
TAG = namespaces.xep0308_replace, "replace"
id_ = xso.Attr(
"id",
)
Message.xep0308_replace = xso.Child([Replace])
aioxmpp/misc/markers.py 0000664 0000000 0000000 00000003043 14160146213 0015502 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: markers.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
# .
#
########################################################################
import aioxmpp.xso
from ..stanza import Message
from aioxmpp.utils import namespaces
namespaces.xep0333_markers = "urn:xmpp:chat-markers:0"
class Marker(aioxmpp.xso.XSO):
id_ = aioxmpp.xso.Attr(
"id"
)
class ReceivedMarker(Marker):
TAG = (namespaces.xep0333_markers, "received")
class DisplayedMarker(Marker):
TAG = (namespaces.xep0333_markers, "displayed")
class AcknowledgedMarker(Marker):
TAG = (namespaces.xep0333_markers, "acknowledged")
Message.xep0333_marker = aioxmpp.xso.Child([
ReceivedMarker,
DisplayedMarker,
AcknowledgedMarker,
])
Message.xep0333_markable = aioxmpp.xso.ChildFlag(
(namespaces.xep0333_markers, "markable"),
)
aioxmpp/misc/oob.py 0000664 0000000 0000000 00000002263 14160146213 0014620 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: oob.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
# .
#
########################################################################
import aioxmpp.xso as xso
from ..stanza import Message
from aioxmpp.utils import namespaces
namespaces.xep0066_oob_x = "jabber:x:oob"
class OOBExtension(xso.XSO):
TAG = namespaces.xep0066_oob_x, "x"
url = xso.ChildText(
(namespaces.xep0066_oob_x, "url")
)
Message.xep0066_oob = xso.Child([OOBExtension])
aioxmpp/misc/openpgp_legacy.py 0000664 0000000 0000000 00000005240 14160146213 0017033 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: openpgp_legacy.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
# .
#
########################################################################
import aioxmpp.stanza
import aioxmpp.xso
from aioxmpp.utils import namespaces
namespaces.xep0027_encrypted = "jabber:x:encrypted"
namespaces.xep0027_signed = "jabber:x:signed"
class OpenPGPEncrypted(aioxmpp.xso.XSO):
"""
Wrapper around an ASCII-armored OpenPGP encrypted blob.
.. warning::
Please see the security considerations of :xep:`27` before making use
of this protocol. Consider implementation of :xep:`373` instead.
See :xep:`27` for details.
.. attribute:: payload
The character data of the wrapper element.
.. note::
While the wire format *is* base64, since the base64 output is
intended to be passed verbatim to OpenPGP, the payload is declared
as normal string and aioxmpp will *not* de-base64 it for you (and
vice versa).
"""
TAG = namespaces.xep0027_encrypted, "x"
payload = aioxmpp.xso.Text()
class OpenPGPSigned(aioxmpp.xso.XSO):
"""
Wrapper around an ASCII-armored OpenPGP signed blob.
.. warning::
Please see the security considerations of :xep:`27` before making use
of this protocol. Consider implementation of :xep:`373` instead.
See :xep:`27` for details.
.. attribute:: payload
The character data of the wrapper element.
.. note::
While the wire format *is* base64, since the base64 output is
intended to be passed verbatim to OpenPGP, the payload is declared
as normal string and aioxmpp will *not* de-base64 it for you (and
vice versa).
"""
TAG = namespaces.xep0027_signed, "x"
payload = aioxmpp.xso.Text()
aioxmpp.stanza.Message.xep0027_encrypted = aioxmpp.xso.Child([OpenPGPEncrypted])
aioxmpp.stanza.Message.xep0027_signed = aioxmpp.xso.Child([OpenPGPSigned])
aioxmpp/misc/pars.py 0000664 0000000 0000000 00000002567 14160146213 0015015 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: pars.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
# .
#
########################################################################
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
from ..stanza import Presence
namespaces.xep0379_pars = "urn:xmpp:pars:0"
class Preauth(xso.XSO):
"""
The preauth element for :xep:`Pre-Authenticated Roster Subcription <379>`.
.. attribute:: token
The pre-auth token associated with this subscription request.
"""
TAG = namespaces.xep0379_pars, "preauth"
token = xso.Attr(
"token",
type_=xso.String(),
)
Presence.xep0379_preauth = xso.Child([Preauth])
aioxmpp/misc/stanzaid.py 0000664 0000000 0000000 00000004564 14160146213 0015664 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: delay.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
# .
#
########################################################################
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
from ..stanza import Message
namespaces.xep0359_stanza_ids = "urn:xmpp:sid:0"
class StanzaID(xso.XSO):
"""
Represent a :xep:`359` Stanza ID.
:param id_: The stanza ID to set
:param by: The entity which has set the stanza ID
:type by: :class:`aioxmpp.JID`
.. attribute:: id_
The assigned stanza ID.
.. attribute:: by
The entity who has assigned the stanza ID.
.. warning::
Stanza IDs may be spoofed. Please take the security considerations of
:xep:`359` and the protocols using it into account.
"""
TAG = (namespaces.xep0359_stanza_ids, "stanza-id")
id_ = xso.Attr("id", default=None)
by = xso.Attr("by", type_=xso.JID(), default=None)
def __init__(self, *, id_=None, by=None, **kwargs):
super().__init__(**kwargs)
self.id_ = id_
self.by = by
class OriginID(xso.XSO):
"""
Represent a :xep:`359` Origin ID.
:param id_: The origin ID to set
.. attribute:: id_
The assigned origin ID.
.. warning::
Origin IDs may be spoofed. Please take the security considerations of
:xep:`359` and the protocols using it into account.
"""
TAG = (namespaces.xep0359_stanza_ids, "origin-id")
id_ = xso.Attr("id", default=None)
def __init__(self, id_=None):
super().__init__()
self.id_ = id_
Message.xep0359_stanza_ids = xso.ChildList([StanzaID])
Message.xep0359_origin_id = xso.Child([OriginID])
aioxmpp/muc/ 0000775 0000000 0000000 00000000000 14160146213 0013315 5 ustar 00root root 0000000 0000000 aioxmpp/muc/__init__.py 0000664 0000000 0000000 00000017115 14160146213 0015433 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.muc` --- Multi-User-Chat support (:xep:`45`)
###########################################################
This subpackage provides client-side support for :xep:`0045`.
.. versionadded:: 0.5
.. versionchanged:: 0.9
Nearly the whole public interface of this module has been re-written in
0.9 to make it coherent with the Modern IM interface defined by
:class:`aioxmpp.im`.
Using Multi-User-Chats
======================
To start using MUCs in your application, you have to load the :class:`Service`
into the client, using :meth:`~.node.Client.summon`.
.. currentmodule:: aioxmpp
.. autoclass:: MUCClient
.. currentmodule:: aioxmpp.muc
.. class:: Service
Alias of :class:`.MUCClient`.
.. deprecated:: 0.8
The alias will be removed in 1.0.
The service returns :class:`Room` objects which are used to track joined MUCs:
.. autoclass:: Room
.. autoclass:: RoomState
.. autoclass:: LeaveMode
Inside rooms, there are occupants:
.. autoclass:: Occupant
.. autoclass:: ServiceMember
Timeout controls / :xep:`0410` (MUC Self-Ping) support
------------------------------------------------------
.. versionadded:: 0.11
:xep:`410` support and aliveness detection.
Motivation
^^^^^^^^^^
In XMPP, multi-user chat services may reside on a server different than the
one the user is at. This may either be due to the service running on a remote
domain, or due to the service being connected via the network to the users
server as component (see e.g. :xep:`114`).
When the connection between the MUC service and the user’s server is broken
when stanzas need to be delivered, stanzas can be lost. This can lead to the
MUC getting "out of sync", in the sense that different participants have
different views of what happens and who even is in the MUC; this uncertainty
can go as far as a client assuming that they’re still joined, while they were
long removed from the MUC.
These types of breakages are hard to detect, unless the user tries to send a
message through the MUC (in which case the lack of reflection or an error reply
will give a clue that something is wrong). In the worst case, with an always-on
client, it may appear that the MUC has been silent for days, while in fact
everyone has been chatting away happily.
Solution
^^^^^^^^
The underlying problem (networks get split) cannot be solved. While Stream
Management on the s2s links could mitigate the issue to some extent, there will
always be limits and circumstances at play which can still cause the
out-of-sync situation.
While loss of messages can be compensated for by fetching the messages from the
archive (doing this automatically on an interruption is out of scope for
aioxmpp), there is no way for an application to detect that the client has been
removed from the MUC except by explicitly pinging or sending messages.
To codify the complex rules which are needed to silently (i.e. invisible to
other participants) check whether a client is still joined, :xep:`410` was
written. It specifies the use of :xep:`199` pings through the MUC to the
clients occupant (i.e. pinging oneself). MUC services explicitly reject the
ping request if the sending client is not an occupant.
.. _api-aioxmpp.muc-self-ping-logic:
Self-Ping Implementation and Logic
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The :xep:`410` implementation in aioxmpp is controlled with several attributes
and lines of defense. In the first line of defense, there is a
:class:`~aioxmpp.utils.AlivenessMonitor` instance (this class is also used to
manage pinging the main XML stream). It is configured through
:attr:`aioxmpp.muc.Room.muc_soft_timeout` and
:attr:`~aioxmpp.muc.Room.muc_hard_timeout`.
The two timers run concurrently. When the soft timeout expires, the pinger (see
below) task is started. When the hard timeout expires, the MUC is marked stale
(this means, the :meth:`~aioxmpp.muc.Room.on_muc_stale` event fires). The
timers for both timeouts are reset whenever a presence or message stanza is
received from the MUC, preventing unnecessary pinging.
The pinger task emits pings in a defined interval
(:attr:`~aioxmpp.muc.Room.muc_ping_interval`). The pings have a timeout of
:attr:`~aioxmpp.muc.Room.muc_ping_timeout`. If a ping is replied to, the result
is interpreted according to :xep:`410`. If the result is positive (= user
still joined), the soft and hard timeout timers mentioned above are reset
(the pinger, thus, ideally prevents the hard timeout from being triggered if
the connection to the MUC is fine after the soft timeout expired). If the
result is inconclusive, pinging continues. If the result is negative (= user
is not joined anymore), the MUC room is marked as exited (with the reason
:attr:`~aioxmpp.muc.LeaveMode.DISCONNECTED`), except if it is set to
autorejoin, in which case a re-join (just as if the XML stream had been
disconnected) is attempted.
The default timeouts are set reasonably high to work reliably even on mobile
links.
.. warning::
Please see the notes on :attr:`~aioxmpp.muc.Room.muc_ping_timeout`
when changing the value of :attr:`~aioxmpp.muc.Room.muc_ping_timeout` or
:attr:`~aioxmpp.muc.Room.muc_ping_interval`.
Forms
=====
.. autoclass:: ConfigurationForm
:members:
.. autoclass:: InfoForm
:members:
.. autoclass:: VoiceRequestForm
:members:
XSOs
====
.. autoclass:: StatusCode
.. currentmodule:: aioxmpp.muc.xso
Attributes added to existing XSOs
---------------------------------
.. attribute:: aioxmpp.Message.xep0045_muc
A :class:`GenericExt` object or :data:`None`.
.. attribute:: aioxmpp.Message.xep0045_muc_user
A :class:`UserExt` object or :data:`None`.
.. attribute:: aioxmpp.Presence.xep0045_muc
A :class:`GenericExt` object or :data:`None`.
.. attribute:: aioxmpp.Presence.xep0045_muc_user
A :class:`UserExt` object or :data:`None`.
.. attribute:: aioxmpp.Message.xep0249_direct_invite
A :class:`DirectInvite` object or :data:`None`.
Generic namespace
-----------------
.. autoclass:: GenericExt
.. autoclass:: History
User namespace
--------------
.. autoclass:: UserExt
.. autoclass:: Status
.. autoclass:: DestroyNotification
.. autoclass:: Decline
.. autoclass:: Invite
.. autoclass:: UserItem
.. autoclass:: UserActor
.. autoclass:: Continue
Admin namespace
---------------
.. autoclass:: AdminQuery
.. autoclass:: AdminItem
.. autoclass:: AdminActor
Owner namespace
---------------
.. autoclass:: OwnerQuery
.. autoclass:: DestroyRequest
:xep:`249` Direct Invitations
-----------------------------
.. autoclass:: DirectInvite
"""
from .service import ( # NOQA: F401
MUCClient,
Occupant,
Room,
LeaveMode,
RoomState,
ServiceMember,
)
from . import xso # NOQA: F401
from .xso import ( # NOQA: F401
ConfigurationForm,
InfoForm,
VoiceRequestForm,
StatusCode,
)
Service = MUCClient # NOQA
aioxmpp/muc/self_ping.py 0000664 0000000 0000000 00000042753 14160146213 0015650 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: self_ping.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
# .
#
########################################################################
import asyncio
import random
import time
from datetime import timedelta
import aioxmpp.errors
import aioxmpp.ping
import aioxmpp.stream
import aioxmpp.structs
import aioxmpp.utils
def _apply_jitter(v, amplitude):
return v * ((random.random() * 2 - 1) * amplitude + 1)
class MUCPinger:
"""
:param on_fresh: Called when the pinger finds evidence that the user is
connected
:param on_exited: Called when the pinger finds evidence that the user is
disconnected
:param loop: Event loop to use
This class manages a coroutine which sends pings to a remote entity and
interprets the results according to :xep:`410`.
If the result of a ping indicates that the client is not joined in the MUC
anymore, `on_exited` is called. If the result of a ping indicates that the
client is still joined in a MUC, `on_stale` is called. If the result are
inconclusive, no call is made.
A ping result does *not* imply a call to :meth:`stop`. The callbacks are
called on each ping response, thus, on average up to once each
:attr:`ping_interval` until :meth:`stop` is called.
Pings are sent once each :attr:`ping_interval` (see there for details on
the effects of changing the interval while the pinger is running). If
:attr:`ping_interval` is less than :attr:`ping_timeout`, it is possible
that multiple pings are in-flight at the same time (this is handled
correctly). Take into account that resources for tracking up to
:attr:`ping_timeout` divided by :attr:`ping_interval` IQ responses will be
required.
To start the pinger, :meth:`start` must be called.
.. automethod:: start
.. automethod:: stop
.. attribute:: ping_address
The address pings are sent to.
This can be changed while the pinger is running. Changes take effect
when the next ping is sent. Already in-flight pings are not affected.
.. autoattribute:: ping_interval
.. autoattribute:: ping_timeout
"""
def __init__(self, ping_address, client, on_fresh, on_exited, logger, loop):
super().__init__()
self.ping_address = ping_address
self._ping_interval = timedelta(minutes=2)
self._ping_timeout = timedelta(minutes=8)
self._client = client
self._on_fresh = on_fresh
self._on_exited = on_exited
self._loop = loop
self._logger = logger
self._task = None
@property
def ping_interval(self) -> timedelta:
"""
The interval at which pings are sent.
While the pinger is running, every `ping_interval` a new ping is
started. Each ping has its individual :attr:`ping_timeout`.
Changing this property takes effect after the next ping has been sent.
Thus, if the :attr:`ping_interval` was set to one day and is then
changed to one minute, it takes up to a day until the one minute
interval starts being used.
"""
return self._ping_interval
@ping_interval.setter
def ping_interval(self, value: timedelta):
# cheap & duck-typey enforcement of timedelta compatibility
self._ping_interval = value + timedelta()
@property
def ping_timeout(self) -> timedelta:
"""
The maximum time to wait for a reply to a ping.
Each ping sent by the pinger has its individual timeout, based on this
property at the time the ping is sent.
"""
return self._ping_timeout
@ping_timeout.setter
def ping_timeout(self, value: timedelta):
# cheap & duck-typey enforcement of timedelta compatibility
self._ping_timeout = value + timedelta()
def start(self):
"""
Start the pinging coroutine using the client and event loop which was
passed to the constructor.
:meth:`start` always behaves as if :meth:`stop` was called right before
it.
"""
self._logger.debug("%s: request to start pinger",
self.ping_address)
self.stop()
self._task = asyncio.ensure_future(self._pinger(), loop=self._loop)
def stop(self):
"""
Stop the pinger (if it is running) and discard all data on in-flight
pings.
This method will do nothing if the pinger is already stopped. It is
idempotent.
"""
self._logger.debug("%s: request to stop pinger",
self.ping_address)
if self._task is None:
self._logger.debug("%s: already stopped", self.ping_address)
return
self._logger.debug("%s: sending cancel signal", self.ping_address)
self._task.cancel()
self._task = None
def _interpret_result(self, task):
"""
Interpret the result of a ping.
:param task: The pinger task.
The result or exception of the `task` is interpreted as follows:
* :data:`None` result: *positive*
* :class:`aioxmpp.errors.XMPPError`, ``service-unavailable``:
*positive*
* :class:`aioxmpp.errors.XMPPError`, ``feature-not-implemented``:
*positive*
* :class:`aioxmpp.errors.XMPPError`, ``item-not-found``: *inconclusive*
* :class:`aioxmpp.errors.XMPPError`, ``remote-server-not-found``:
*inconclusive*
* :class:`aioxmpp.errors.XMPPError`, ``remote-server-timeout``:
*inconclusive*
* :class:`aioxmpp.errors.XMPPError`: *negative*
* :class:`asyncio.TimeoutError`: *inconclusive*
* Any other exception: *inconclusive*
"""
if task.exception() is None:
self._logger.debug("%s: ping reply has no error -> emitting fresh "
"event", self.ping_address)
self._on_fresh()
return
exc = task.exception()
if isinstance(exc, aioxmpp.errors.XMPPError):
if exc.condition in [
aioxmpp.errors.ErrorCondition.SERVICE_UNAVAILABLE,
aioxmpp.errors.ErrorCondition.FEATURE_NOT_IMPLEMENTED]:
self._logger.debug(
"%s: ping reply has error indicating freshness: %s",
self.ping_address,
exc.condition,
)
self._on_fresh()
return
if exc.condition in [
aioxmpp.errors.ErrorCondition.ITEM_NOT_FOUND,
aioxmpp.errors.ErrorCondition.REMOTE_SERVER_NOT_FOUND,
aioxmpp.errors.ErrorCondition.REMOTE_SERVER_TIMEOUT]:
self._logger.debug(
"%s: ping reply has inconclusive error: %s",
self.ping_address,
exc.condition,
)
return
self._logger.debug(
"%s: ping reply has error indicating that the client got "
"removed: %s",
self.ping_address,
exc.condition,
)
self._on_exited()
async def _pinger(self):
in_flight = []
next_ping_at = None
self._logger.debug("%s: pinger booted up", self.ping_address)
try:
while True:
self._logger.debug("%s: pinger loop. interval=%r",
self.ping_address,
self.ping_interval)
now = time.monotonic()
ping_interval = self.ping_interval.total_seconds()
if next_ping_at is None:
next_ping_at = now - 1
timeout = next_ping_at - now
if timeout <= 0:
# do not send pings while the client is in suspended state
# (= Stream Management hibernation). This will only add to
# the queue for no good reason, we won’t get any reply soon
# anyways.
if self._client.suspended:
self._logger.debug(
"%s: omitting self-ping, as the stream is "
"currently hibernated",
self.ping_address,
)
else:
self._logger.debug(
"%s: sending self-ping with timeout %r",
self.ping_address,
self.ping_timeout,
)
in_flight.append(asyncio.ensure_future(
asyncio.wait_for(
aioxmpp.ping.ping(self._client,
self.ping_address),
self.ping_timeout.total_seconds()
)
))
next_ping_at = now + _apply_jitter(ping_interval, 0.1)
timeout = ping_interval
assert timeout > 0
if not in_flight:
self._logger.debug(
"%s: pinger has nothing to do, sleeping for %s",
self.ping_address,
timeout,
)
await asyncio.sleep(timeout)
continue
self._logger.debug(
"%s: pinger waiting for %d pings for at most %ss",
self.ping_address,
len(in_flight),
timeout,
)
done, pending = await asyncio.wait(
in_flight,
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
)
for fut in done:
self._interpret_result(fut)
in_flight = list(pending)
finally:
self._logger.debug("%s: pinger exited", self.ping_address,
exc_info=True)
for fut in in_flight:
if not fut.done():
fut.cancel()
class MUCMonitor:
"""
:param ping_address: Address to send pings to. Can be changed later with
:attr:`ping_address`.
:type ping_address: :class:`aioxmpp.JID`
:param client: Client to send pings with.
:type stream: :class:`aioxmpp.stream.StanzaStream`
:param on_stale: Called when the pinger detects stale state.
:param on_fresh: Called when the pinger detects fresh state.
:param on_exited: Called when the pinger detects that the user is not in
the room anymore.
:param loop: Event loop to use (defaults to the current event loop)
.. automethod:: enable
.. automethod:: disable
.. automethod:: reset
.. attribute:: ping_address
The address to ping.
.. autoattribute:: stream
.. autoattribute:: is_stale
.. autoattribute:: soft_timeout
.. autoattribute:: hard_timeout
.. autoattribute:: ping_interval
.. autoattribute:: ping_timeout
"""
def __init__(self,
ping_address: aioxmpp.structs.JID,
client: "aioxmpp.node.Client",
on_stale,
on_fresh,
on_exited,
logger,
loop=None):
loop = loop or asyncio.get_event_loop()
super().__init__()
self._client = client
self._is_stale = False
self.on_stale = on_stale
self.on_fresh = on_fresh
self.on_exited = on_exited
self._soft_timeout = timedelta(minutes=13)
self._hard_timeout = timedelta(minutes=15)
self._monitor = aioxmpp.utils.AlivenessMonitor(loop)
# disable the monitor altogether
self._monitor.deadtime_hard_limit = None
self._monitor.deadtime_soft_limit = None
self._monitor_enabled = False
self._monitor.on_deadtime_hard_limit_tripped.connect(
self._hard_limit_tripped
)
self._monitor.on_deadtime_soft_limit_tripped.connect(
self._soft_limit_tripped
)
self._logger = logger
self._pinger = MUCPinger(
ping_address,
client,
self._pinger_fresh_detected,
self._pinger_exited_detected,
logger,
loop,
)
self.ping_address = ping_address
@property
def is_stale(self) -> bool:
return self._is_stale
@property
def soft_timeout(self) -> timedelta:
return self._soft_timeout
@soft_timeout.setter
def soft_timeout(self, new_value: timedelta):
# cheap & duck-typey enforcement of timedelta compatibility
self._soft_timeout = new_value + timedelta()
if self._monitor_enabled:
self._monitor.deadtime_soft_limit = new_value
@property
def hard_timeout(self) -> timedelta:
return self._hard_timeout
@hard_timeout.setter
def hard_timeout(self, new_value: timedelta):
# cheap & duck-typey enforcement of timedelta compatibility
self._hard_timeout = new_value + timedelta()
if self._monitor_enabled:
self._monitor.deadtime_hard_limit = new_value
ping_address = aioxmpp.utils.proxy_property(
"_pinger",
"ping_address",
)
ping_timeout = aioxmpp.utils.proxy_property(
"_pinger",
"ping_timeout",
)
ping_interval = aioxmpp.utils.proxy_property(
"_pinger",
"ping_interval",
)
def enable(self):
"""
Enable the monitor, if it is not enabled already.
If the monitor is not already enabled, the aliveness timeouts are reset
and configured and the stale state is cleared.
"""
self._logger.debug("%s: request to enable monitoring",
self.ping_address)
if self._monitor_enabled:
return
self._is_stale = False
self._enable_monitor()
def disable(self):
"""
Disable the monitor.
Reset and stop the aliveness timeouts. Cancel and stop pinging.
"""
self._disable_monitor()
self._pinger.stop()
def reset(self):
"""
Reset the monitor.
Reset the aliveness timeouts. Clear the stale state. Cancel and stop
pinging.
Call `on_fresh` if the stale state was set.
"""
self._monitor.notify_received()
self._pinger.stop()
self._mark_fresh()
def _mark_stale(self):
"""
- Emit on_stale if stale flag is cleared
- Set stale flag
"""
if not self._is_stale:
self._logger.debug("%s: transition to stale", self.ping_address)
self.on_stale()
self._is_stale = True
def _mark_fresh(self):
"""
- Emit on_fresh if stale flag is set
- Clear stale flag
"""
if self._is_stale:
self._logger.debug("%s: transition to fresh", self.ping_address)
self.on_fresh()
self._is_stale = False
def _enable_monitor(self):
# we need to call notify received *first* to prevent spurious events
self._monitor.notify_received()
self._monitor.deadtime_soft_limit = self._soft_timeout
self._monitor.deadtime_hard_limit = self._hard_timeout
self._monitor_enabled = True
self._logger.debug("%s: enabled monitoring: "
"soft_timeout=%r "
"hard_timeout=%r "
"ping_interval=%r "
"ping_timeout=%r",
self.ping_address,
self._soft_timeout,
self._hard_timeout,
self.ping_interval,
self.ping_timeout)
def _disable_monitor(self):
# we need to call notify received *first* to prevent spurious events
self._monitor.notify_received()
self._monitor.deadtime_soft_limit = None
self._monitor.deadtime_hard_limit = None
self._monitor_enabled = False
self._logger.debug("%s: disabled monitoring", self.ping_address)
def _pinger_fresh_detected(self):
self._logger.debug("%s: fresh detected", self.ping_address)
self._pinger.stop()
self._monitor.notify_received()
self._mark_fresh()
def _pinger_exited_detected(self):
self._logger.debug("%s: exited detected", self.ping_address)
self._pinger.stop()
self.on_exited()
def _soft_limit_tripped(self):
self._logger.debug("%s: soft-limit tripped, starting pinger",
self.ping_address)
self._pinger.start()
def _hard_limit_tripped(self):
self._logger.debug("%s: hard-limit tripped, marking stale",
self.ping_address)
self._mark_stale()
aioxmpp/muc/service.py 0000664 0000000 0000000 00000250604 14160146213 0015336 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import functools
import uuid
from datetime import datetime
from enum import Enum
import aioxmpp.callbacks
import aioxmpp.disco
import aioxmpp.forms
import aioxmpp.service
import aioxmpp.stanza
import aioxmpp.structs
import aioxmpp.tracking
import aioxmpp.im.conversation
import aioxmpp.im.dispatcher
import aioxmpp.im.p2p
import aioxmpp.im.service
import aioxmpp.utils
from aioxmpp.utils import namespaces
from . import self_ping
from . import xso as muc_xso
def _extract_one_pair(body):
"""
Extract one language-text pair from a :class:`~.LanguageMap`.
This is used for tracking.
"""
if not body:
return None, None
try:
return None, body[None]
except KeyError:
return min(body.items(), key=lambda x: x[0])
class LeaveMode(Enum):
"""
The different reasons for a user to leave or be removed from MUC.
.. attribute:: DISCONNECTED
The local client disconnected. This only occurs in events referring to
the local entity.
.. attribute:: SYSTEM_SHUTDOWN
The remote server shut down.
.. attribute:: NORMAL
The leave was initiated by the occupant themselves and was not a kick or
ban.
.. attribute:: KICKED
The user was kicked from the room.
.. attribute:: AFFILIATION_CHANGE
Changes in the affiliation of the user caused them to be removed.
.. attribute:: MODERATION_CHANGE
Changes in the moderation settings of the room caused the user to be
removed.
.. attribute:: BANNED
The user was banned from the room.
.. attribute:: ERROR
The user was removed due to an error when communicating with the client
or the users server.
Not all servers support this. If not supported by the server, one will
typically see a :attr:`KICKED` status code with an appropriate
:attr:`~.Presence.status` message.
.. versionadded:: 0.10
"""
DISCONNECTED = -2
SYSTEM_SHUTDOWN = -1
NORMAL = 0
KICKED = 1
AFFILIATION_CHANGE = 3
MODERATION_CHANGE = 4
BANNED = 5
ERROR = 6
class _OccupantDiffClass(Enum):
UNIMPORTANT = 0
NICK_CHANGED = 1
LEFT = 2
class Occupant(aioxmpp.im.conversation.AbstractConversationMember):
"""
A tracking object to track a single occupant in a :class:`Room`.
.. seealso::
:class:`~.AbstractConversationMember`
for additional notes on some of the pre-defined attributes.
.. autoattribute:: direct_jid
.. autoattribute:: conversation_jid
.. autoattribute:: uid
.. autoattribute:: nick
.. attribute:: presence_state
The :class:`~.PresenceState` of the occupant.
.. attribute:: presence_status
The :class:`~.LanguageMap` holding the presence status text of the
occupant.
.. attribute:: affiliation
The affiliation of the occupant with the room. This may be :data:`None`
with faulty MUC implementations.
.. attribute:: role
The current role of the occupant within the room. This may be
:data:`None` with faulty MUC implementations.
"""
def __init__(self,
occupantjid,
is_self,
presence_state=aioxmpp.structs.PresenceState(available=True),
presence_status={},
affiliation=None,
role=None,
jid=None):
super().__init__(occupantjid, is_self)
self.presence_state = presence_state
self.presence_status = aioxmpp.structs.LanguageMap(presence_status)
self.affiliation = affiliation
self.role = role
self._direct_jid = jid
if jid is None:
self._uid = b"urn:uuid:" + uuid.uuid4().bytes
else:
self._set_uid_from_direct_jid(self._direct_jid)
if not self._direct_jid.is_bare:
raise ValueError("the jid argument must be a bare JID")
def _set_uid_from_direct_jid(self, jid):
self._uid = b"xmpp:" + str(jid.bare()).encode("utf-8")
@property
def direct_jid(self):
"""
The real :class:`~aioxmpp.JID` of the occupant.
If the MUC is anonymous and we do not have the permission to see the
real JIDs of occupants, this is :data:`None`.
"""
return self._direct_jid
@property
def nick(self):
"""
The nickname of the occupant.
"""
return self.conversation_jid.resource
@property
def uid(self):
"""
This is either a random identifier if the real JID of the occupant is
not known, or an identifier derived from the real JID of the occupant.
Note that as per the semantics of the :attr:`uid`, users **must** treat
it as opaque.
.. seealso::
:class:`~aioxmpp.im.conversation.AbstractConversationMember.uid`
Documentation of the attribute on the base class, with
additional information on semantics.
"""
return self._uid
@classmethod
def from_presence(cls, presence, is_self):
try:
item = presence.xep0045_muc_user.items[0]
except (AttributeError, IndexError):
affiliation = None
if presence.type_ == aioxmpp.structs.PresenceType.UNAVAILABLE:
role = "none" # unavailable must be the "none" role
else:
role = None
jid = None
else:
affiliation = item.affiliation
role = item.role
jid = item.bare_jid
return cls(
occupantjid=presence.from_,
is_self=is_self,
presence_state=aioxmpp.structs.PresenceState.from_stanza(presence),
presence_status=aioxmpp.structs.LanguageMap(presence.status),
affiliation=affiliation,
role=role,
jid=jid,
)
def update(self, other):
if self.conversation_jid != other.conversation_jid:
raise ValueError("occupant JID mismatch")
self.presence_state = other.presence_state
self.presence_status.clear()
self.presence_status.update(other.presence_status)
self.affiliation = other.affiliation or self.affiliation
self.role = other.role or self.role
if self._direct_jid is None and other.direct_jid is not None:
self._set_uid_from_direct_jid(other.direct_jid)
self._direct_jid = other.direct_jid or self._direct_jid
def __repr__(self):
return "<{}.{} occupantjid={!r} uid={!r} jid={!r}>".format(
type(self).__module__,
type(self).__qualname__,
self._conversation_jid,
self._uid,
self._direct_jid,
)
class RoomState(Enum):
"""
Enumeration which describes the state a :class:`~.muc.Room` is in.
.. attribute:: JOIN_PRESENCE
The room is in the process of being joined and the presence state
transfer is going on.
.. attribute:: HISTORY
Presence state transfer has happened, but the room subject has not
been received yet. This is where history replay messages are
received.
When entering this state, :attr:`~.muc.Room.muc_active` becomes true.
.. attribute:: ACTIVE
The join has completed, including history replay and receiving the
subject.
.. attribute:: DISCONNECTED
The MUC is suspended or disconnected. If the MUC is disconnected,
:attr:`~.muc.Room.muc_joined` will be false, too.
"""
JOIN_PRESENCE = 0
HISTORY = 1
ACTIVE = 2
DISCONNECTED = 3
class ServiceMember(aioxmpp.im.conversation.AbstractConversationMember):
"""
A :class:`~aioxmpp.im.conversation.AbstractConversationMember` which
represents a MUC service.
.. versionadded:: 0.10
Objects of this instance are used for the :attr:`Room.service_member`
property of rooms.
Aside from the mandatory conversation member attributes, the following
attributes for compatibility with :class:`Occupant` are provided:
.. autoattribute:: nick
.. attribute:: presence_state
:annotation: aioxmpp.structs.PresenceState(False)
The presence state of the service. Always unavailable.
.. attribute:: presence_status
:annotation: {}
The presence status of the service as :class:`~.LanguageMap`. Always
empty.
.. attribute:: affiliation
:annotation: None
The affiliation of the service. Always :data:`None`.
.. attribute:: role
:annotation: None
The role of the service. Always :data:`None`.
"""
def __init__(self, muc_address):
super().__init__(muc_address, False)
self.presence_state = aioxmpp.structs.PresenceState(False)
self.presence_status = aioxmpp.structs.LanguageMap()
self.affiliation = None
self.role = None
self._uid = b"xmpp:" + str(muc_address).encode("utf-8")
@property
def direct_jid(self):
return self.conversation_jid
@property
def nick(self):
return None
@property
def uid(self) -> bytes:
return self._uid
class Room(aioxmpp.im.conversation.AbstractConversation):
"""
:term:`Conversation` representing a single :xep:`45` Multi-User Chat.
.. note::
This is an implementation of :class:`~.AbstractConversation`. The
members which do not carry the ``muc_`` prefix usually have more
extensive documentation there. This documentation here only provides
a short synopsis for those members plus the changes with respect to
the base interface.
.. versionchanged:: 0.9
In 0.9, the :class:`Room` interface was re-designed to match
:class:`~.AbstractConversation`.
The following properties are provided:
.. autoattribute:: features
.. autoattribute:: jid
.. autoattribute:: me
.. autoattribute:: members
.. autoattribute:: service_member
These properties are specific to MUC:
.. autoattribute:: muc_active
.. autoattribute:: muc_joined
.. autoattribute:: muc_state
.. autoattribute:: muc_subject
.. autoattribute:: muc_subject_setter
.. attribute:: muc_autorejoin
A boolean flag indicating whether this MUC is supposed to be
automatically rejoined when the stream it is used gets destroyed and
re-estabished.
.. attribute:: muc_password
The password to use when (re-)joining. If :attr:`autorejoin` is
:data:`None`, this can be cleared after :meth:`on_enter` has been
emitted.
The following methods and properties provide interaction with the MUC
itself:
.. automethod:: ban
.. automethod:: kick
.. automethod:: leave
.. automethod:: send_message
.. automethod:: send_message_tracked
.. automethod:: set_nick
.. automethod:: set_topic
.. automethod:: muc_request_voice
.. automethod:: muc_set_role
.. automethod:: muc_set_affiliation
The interface provides signals for most of the rooms events. The following
keyword arguments are used at several signal handlers (which is also noted
at their respective documentation):
`muc_actor` = :data:`None`
The :class:`~.xso.UserActor` instance of the corresponding
:class:`~.xso.UserExt`, describing which other occupant caused the
event.
Note that the `muc_actor` is in fact not a :class:`~.Occupant`.
`muc_reason` = :data:`None`
The reason text in the corresponding :class:`~.xso.UserExt`, which
gives more information on why an action was triggered.
.. note::
Signal handlers attached to any of the signals below **must** accept
arbitrary keyword arguments for forward compatibility. For details see
the documentation on :class:`~.AbstractConversation`.
.. signal:: on_enter(**kwargs)
Emits when the initial room :class:`~.Presence` stanza for the
local JID is received. This means that the join to the room is
complete; the message history and subject are not transferred yet
though.
.. seealso::
:meth:`on_muc_enter`
is an extended version of this signal which contains additional
MUC-specific information.
.. versionchanged:: 0.10
The :meth:`on_enter` signal does not receive any arguments anymore
to make MUC comply with the :class:`AbstractConversation` spec.
.. signal:: on_muc_enter(presence, occupant, *, muc_status_codes=set(), **kwargs)
This is an extended version of :meth:`on_enter` which adds MUC-specific
arguments.
:param presence: The initial presence stanza.
:param occupant: The :class:`Occupant` which will be used to track the
local user.
:param muc_status_codes: The set of status codes received in the
initial join.
:type muc_status_codes: :class:`~.abc.Set` of :class:`int` or
:class:`~.StatusCode`
.. versionadded:: 0.10
.. signal:: on_message(msg, member, source, **kwargs)
A message occurred in the conversation.
:param msg: Message which was received.
:type msg: :class:`aioxmpp.Message`
:param member: The member object of the sender.
:type member: :class:`.Occupant`
:param source: How the message was acquired
:type source: :class:`~.MessageSource`
The notable specialities about MUCs compared to the base specification
at :meth:`.AbstractConversation.on_message` are:
* Carbons do not happen for MUC messages.
* MUC Private Messages are not handled here; see :class:`MUCClient` for
MUC PM details.
* MUCs reflect messages; to make this as easy to handle as possible,
reflected messages are **not** emitted via the :meth:`on_message`
event **if and only if** they were sent with tracking (see
:meth:`send_message_tracked`) and they were detected as reflection.
See :meth:`send_message_tracked` for details and caveats on the
tracking implementation.
When **history replay** happens, since joins and leaves are not part of
the history, it is not always possible to reason about the identity of
the sender of a history message. To avoid possible spoofing attacks,
the following caveats apply to the :class:`~.Occupant` objects handed
as `member` during history replay:
* Two identical :class:`~.Occupant` objects are only used *iff* the
nickname *and* the actual address of the entity are equal. This
implies that unless this client has the permission to see JIDs of
occupants of the MUC, all :class:`~.Occupant` objects during history
replay will be different instances.
* If the nickname and the actual address of a message from history
match, the current :class:`~.Occupant` object for the respective
occupant is used.
* :class:`~.Occupant` objects which are created for history replay are
never part of :attr:`members`. They are only used to convey the
information passed in the messages from the history replay, which
would otherwise be inaccessible.
.. seealso::
:meth:`.AbstractConversation.on_message` for the full
specification.
.. signal:: on_presence_changed(member, resource, presence, **kwargs)
The presence state of an occupant has changed.
:param member: The member object of the affected member.
:type member: :class:`Occupant`
:param resource: The resource of the member which changed presence.
:type resource: :class:`str` or :data:`None`
:param presence: The presence stanza
:type presence: :class:`aioxmpp.Presence`
`resource` is always :data:`None` for MUCs and unavailable presence
implies that the occupant left the room. In this case, only
:meth:`on_leave` is emitted.
.. seealso::
:meth:`.AbstractConversation.on_presence_changed` for the full
specification.
.. signal:: on_nick_changed(member, old_nick, new_nick, *, muc_status_codes=set(), **kwargs)
The nickname of an occupant has changed
:param member: The occupant whose nick has changed.
:type member: :class:`Occupant`
:param old_nick: The old nickname of the member.
:type old_nick: :class:`str` or :data:`None`
:param new_nick: The new nickname of the member.
:type new_nick: :class:`str`
:param muc_status_codes: The set of status codes received in the leave
notification.
:type muc_status_codes: :class:`~.abc.Set` of :class:`int` or
:class:`~.StatusCode`
The new nickname is already set in the `member` object. Both `old_nick`
and `new_nick` are not :data:`None`.
.. seealso::
:meth:`.AbstractConversation.on_nick_changed` for the full
specification.
.. versionchanged:: 0.10
The `muc_status_codes` argument was added.
.. signal:: on_topic_changed(member, new_topic, *, muc_nick=None, **kwargs)
The topic of the conversation has changed.
:param member: The member object who changed the topic.
:type member: :class:`Occupant` or :data:`None`
:param new_topic: The new topic of the conversation.
:type new_topic: :class:`.LanguageMap`
:param muc_nick: The nickname of the occupant who changed the topic.
:type muc_nick: :class:`str`
The `member` is matched by nickname. It is possible that the member is
not in the room at the time the topic change is received (for example
on a join).
`muc_nick` is always the nickname of the entity who changed the topic.
If the entity is currently not joined or has changed nick since the
topic was set, `member` will be :data:`None`, but `muc_nick` is still
the nickname of the actor.
.. note::
:meth:`on_topic_changed` is emitted during join, iff a topic is set
in the MUC.
.. signal:: on_join(member, **kwargs)
A new occupant has joined the MUC.
:param member: The member object of the new member.
:type member: :class:`Occupant`
When this signal is called, the `member` is already included in the
:attr:`members`.
.. signal:: on_leave(member, *, muc_leave_mode=None, muc_actor=None, muc_reason=None, **kwargs)
An occupant has left the conversation.
:param member: The member object of the previous occupant.
:type member: :class:`Occupant`
:param muc_leave_mode: The cause of the removal.
:type muc_leave_mode: :class:`LeaveMode` member
:param muc_actor: The actor object if available.
:type muc_actor: :class:`~.xso.UserActor`
:param muc_reason: The reason for the cause, as given by the actor.
:type muc_reason: :class:`str`
:param muc_status_codes: The set of status codes received in the leave
notification.
:type muc_status_codes: :class:`~.abc.Set` of :class:`int` or
:class:`~.StatusCode`
When this signal is called, the `member` has already been removed from
the :attr:`members`.
.. versionchanged:: 0.10
The `muc_status_codes` argument was added.
.. signal:: on_muc_suspend()
Emits when the stream used by this MUC gets destroyed (see
:meth:`~.node.Client.on_stream_destroyed`) and the MUC is configured to
automatically rejoin the user when the stream is re-established.
.. signal:: on_muc_resume()
Emits when the MUC is about to be rejoined on a new stream. This can be
used by implementations to clear their MUC state, as it is emitted
*before* any events like presence are emitted.
The internal state of :class:`Room` is cleared before :meth:`on_resume`
is emitted, which implies that presence events will be emitted for all
occupants on re-join, independent on their presence before the
connection was lost.
Note that on a rejoin, all presence is re-emitted.
.. signal:: on_muc_role_request(form, submission_future)
Emits when an unprivileged occupant requests a role change and the
MUC service wants this occupant to approve or deny it.
:param form: The approval form as presented by the service.
:type form: :class:`~.VoiceRequestForm`
:param submission_future: A future to which the form to submit must
be sent.
:type submission_future: :class:`asyncio.Future`
To decide on a role change request, a handler of this signal must
fill in the form and set the form as a result of the
`submission_future`.
Once the result is set, the reply is sent by the MUC service
automatically.
It is required for signal handlers to check whether the
`submission_future` is already done before processing the form (as it
is possible that multiple handlers are connected to this signal).
.. signal:: on_exit(*, muc_leave_mode=None, muc_actor=None, muc_reason=None, muc_status_codes=set(), **kwargs)
Emits when the unavailable :class:`~.Presence` stanza for the
local JID is received.
:param muc_leave_mode: The cause of the removal.
:type muc_leave_mode: :class:`LeaveMode` member
:param muc_actor: The actor object if available.
:type muc_actor: :class:`~.xso.UserActor`
:param muc_reason: The reason for the cause, as given by the actor.
:type muc_reason: :class:`str`
:param muc_status_codes: The set of status codes received in the leave
notification.
:type muc_status_codes: :class:`~.abc.Set` of :class:`int` or
:class:`~.StatusCode`
.. note::
The keyword arguments `muc_actor`, `muc_reason` and
`muc_status_codes` are not always given. Be sure to default them
accordingly.
.. versionchanged:: 0.10
The `muc_status_codes` argument was added.
.. signal:: on_muc_stale(**kwargs)
Emits when the :attr:`muc_hard_timeout` expires.
This signal is emitted only up to once for each pause in data
reception. As long as data is received often enough, the timeout will
not trigger. When the timeout triggers due to silence, the signal is
emitted once, and not again until after data has been received for the
next time.
This signal is only informational. It does not imply that the MUC is
unreachable or that the local occupant has been removed from the MUC,
but it is very likely that no messages can currently be sent or
received.
It is not clear whether messages are being lost.
A prominent example on when this condition can occur is highlighted in
the specification for the feature this is built on (:xep:`0410`).
Often, the MUC service is on a remote domain, which means that there
are at least two network connections involved, sometimes three (c2s,
s2s, and from the remote server to the MUC component).
When the s2s connection (for example) fails in certain ways, it is
possible that no error replies are generated by any party; stanzas are
essentially blackholed. When the network connection resumes, it depends
on the exact failure mode whether the occupant is still in the room and
which messages (if any) which were sent in the meantime will have been
delivered to any participant.
After :meth:`on_muc_stale` emits, exactly one of the following will
happen, given infinite time:
- :meth:`on_muc_fresh` is emitted, which means that connectivity to
the MUC has been re-confirmed.
- :meth:`on_muc_suspend` is emitted, which means that the local client
has disconnected (but autorejoin is enabled).
- :meth:`on_exit` is emitted, which means that the client has been
removed from the MUC or the local client has disconnected (and
autorejoin is disabled).
The aliveness checks are only enabled after presence synchronisation
has begun.
.. signal:: on_muc_fresh(**kwargs)
Emits after :meth:`on_muc_stale` when connectivity is re-confirmed.
See :meth:`on_muc_stale` for details.
The following signals inform users about state changes related to **other**
occupants in the chat room. Note that different events may fire for the
same presence stanza. A common example is a ban, which triggers
:meth:`on_affiliation_change` (as the occupants affiliation is set to
``"outcast"``) and then :meth:`on_leave` (with :attr:`LeaveMode.BANNED`
`mode`).
.. signal:: on_muc_affiliation_changed(member, *, actor=None, reason=None, status_codes=set(), **kwargs)
Emits when the affiliation of a `member` with the room changes.
:param occupant: The member of the room.
:type occupant: :class:`Occupant`
:param actor: The actor object if available.
:type actor: :class:`~.xso.UserActor`
:param reason: The reason for the change, as given by the actor.
:type reason: :class:`str`
:param status_codes: The set of status codes received in the change
notification.
:type status_codes: :class:`~.abc.Set` of :class:`int` or
:class:`~.StatusCode`
`occupant` is the :class:`Occupant` instance tracking the occupant
whose affiliation changed.
.. versionchanged:: 0.10
The `status_codes` argument was added.
.. signal:: on_muc_role_changed(member, *, actor=None, reason=None, status_codes=set(), **kwargs)
Emits when the role of an `occupant` in the room changes.
:param occupant: The member of the room.
:type occupant: :class:`Occupant`
:param actor: The actor object if available.
:type actor: :class:`~.xso.UserActor`
:param reason: The reason for the change, as given by the actor.
:type reason: :class:`str`
:param status_codes: The set of status codes received in the change
notification.
:type status_codes: :class:`~.abc.Set` of :class:`int` or
:class:`~.StatusCode`
`occupant` is the :class:`Occupant` instance tracking the occupant
whose role changed.
.. versionchanged:: 0.10
The `status_codes` argument was added.
Timeout control:
.. seealso::
:ref:`api-aioxmpp.muc-self-ping-logic`
.. attribute:: muc_soft_timeout
The soft timeout of the MUC aliveness timeout logic as
:class:`datetime.timedelta`.
.. versionadded:: 0.11
.. attribute:: muc_hard_timeout
The hard timeout of the MUC aliveness timeout logic as
:class:`datetime.timedelta`.
.. versionadded:: 0.11
.. attribute:: muc_ping_interval
The interval at which pings are sent after the soft timeout expires as
:class:`datetime.timedelta`.
.. warning::
Please see the notes on :attr:`muc_ping_timeout`
when changing the value of :attr:`muc_ping_timeout` or
:attr:`muc_ping_interval`.
.. versionadded:: 0.11
.. attribute:: muc_ping_timeout
The maximum time to wait for a ping reply for each individual ping as
:class:`datetime.timedelta`.
.. warning::
Pings are continued to be sent even when other pings are already
in-flight. This means that up to
``math.ceil(muc_ping_timeout / muc_ping_interval)`` pings are
in-flight at the same time. Each ping which is in-flight
unfortunately requires a small amount of memory and an entry in a
map which associates the stanza ID with the handler/future for the
reply.
.. versionadded:: 0.11
.. seealso::
:ref:`api-aioxmpp.muc-self-ping-logic`
""" # NOQA: E501
# this occupant state events
on_muc_suspend = aioxmpp.callbacks.Signal()
on_muc_resume = aioxmpp.callbacks.Signal()
on_muc_enter = aioxmpp.callbacks.Signal()
on_muc_stale = aioxmpp.callbacks.Signal()
on_muc_fresh = aioxmpp.callbacks.Signal()
# other occupant state events
on_muc_affiliation_changed = aioxmpp.callbacks.Signal()
on_muc_role_changed = aioxmpp.callbacks.Signal()
# approval requests
on_muc_role_request = aioxmpp.callbacks.Signal()
def __init__(self, service, mucjid):
super().__init__(service)
self._mucjid = mucjid
self._occupant_info = {}
self._subject = aioxmpp.structs.LanguageMap()
self._subject_setter = None
self._joined = False
self._active = False
self._this_occupant = None
self._tracking_by_id = {}
self._tracking_metadata = {}
self._tracking_by_body = {}
self._state = RoomState.JOIN_PRESENCE
self._history_replay_occupants = {}
self._service_member = ServiceMember(mucjid)
self.muc_autorejoin = False
self.muc_password = None
self._monitor = self_ping.MUCMonitor(
mucjid,
service.client,
self._monitor_stale,
self._monitor_fresh,
self._monitor_exited,
self._service.logger.getChild("MUCMonitor"),
)
@property
def service(self):
return self._service
@property
def muc_state(self):
"""
The state the MUC is in. This is one of the
:class:`~.muc.RoomState` enumeration values. See there for
documentation on the meaning.
This state is more detailed than :attr:`muc_active`.
"""
return self._state
@property
def muc_active(self):
"""
A boolean attribute indicating whether the connection to the MUC is
currently live.
This becomes true when :attr:`joined` first becomes true. It becomes
false whenever the connection to the MUC is interrupted in a way which
requires re-joining the MUC (this implies that if stream management is
being used, active does not become false on temporary connection
interruptions).
"""
return self._active
@property
def muc_joined(self):
"""
This attribute becomes true when :meth:`on_enter` is first emitted and
stays true until :meth:`on_exit` is emitted.
When it becomes false, the :class:`Room` is removed from the
bookkeeping of the :class:`.MUCClient` to which it belongs and is thus
dead.
"""
return self._joined
@property
def muc_subject(self):
"""
The current subject of the MUC, as :class:`~.structs.LanguageMap`.
"""
return self._subject
@property
def muc_subject_setter(self):
"""
The nick name of the entity who set the subject.
"""
return self._subject_setter
@property
def me(self):
"""
A :class:`Occupant` instance which tracks the local user. This is
:data:`None` until :meth:`on_enter` is emitted; it is never set to
:data:`None` again, but the identity of the object changes on each
:meth:`on_enter`.
"""
return self._this_occupant
@property
def jid(self):
"""
The (bare) :class:`aioxmpp.JID` of the MUC which this :class:`Room`
tracks.
"""
return self._mucjid
@property
def members(self):
"""
A copy of the list of occupants. The local user is always the first
item in the list, unless the :meth:`on_enter` has not fired yet.
"""
if self._this_occupant is not None:
items = [self._this_occupant]
else:
items = []
items += list(self._occupant_info.values())
return items
@property
def service_member(self):
"""
A :class:`ServiceMember` object which represents the MUC service
itself.
This is used when messages from the MUC service are received.
.. seealso::
:attr:`~aioxmpp.im.conversation.AbstractConversation.service_member`
For more documentation on the semantics of
:attr:`~.service_member`.
.. versionadded:: 0.10
"""
return self._service_member
@property
def features(self):
"""
The set of features supported by this MUC. This may vary depending on
features exported by the MUC service, so be sure to check this for each
individual MUC.
"""
return {
aioxmpp.im.conversation.ConversationFeature.BAN,
aioxmpp.im.conversation.ConversationFeature.BAN_WITH_KICK,
aioxmpp.im.conversation.ConversationFeature.KICK,
aioxmpp.im.conversation.ConversationFeature.SEND_MESSAGE,
aioxmpp.im.conversation.ConversationFeature.SEND_MESSAGE_TRACKED,
aioxmpp.im.conversation.ConversationFeature.SET_TOPIC,
aioxmpp.im.conversation.ConversationFeature.SET_NICK,
aioxmpp.im.conversation.ConversationFeature.INVITE,
aioxmpp.im.conversation.ConversationFeature.INVITE_DIRECT,
}
muc_soft_timeout = aioxmpp.utils.proxy_property(
"_monitor",
"soft_timeout",
)
muc_hard_timeout = aioxmpp.utils.proxy_property(
"_monitor",
"hard_timeout",
)
muc_ping_timeout = aioxmpp.utils.proxy_property(
"_monitor",
"ping_timeout",
)
muc_ping_interval = aioxmpp.utils.proxy_property(
"_monitor",
"ping_interval",
)
def _enter_active_state(self):
self._state = RoomState.ACTIVE
self._history_replay_occupants.clear()
def _suspend(self):
self._monitor.disable()
self.on_muc_suspend()
self._active = False
self._state = RoomState.DISCONNECTED
self._history_replay_occupants.clear()
def _disconnect(self):
if not self._joined:
return
self._monitor.disable()
self.on_exit(
muc_leave_mode=LeaveMode.DISCONNECTED
)
self._joined = False
self._active = False
self._state = RoomState.DISCONNECTED
self._history_replay_occupants.clear()
def _resume(self):
self._this_occupant = None
self._occupant_info = {}
self._active = False
self._state = RoomState.JOIN_PRESENCE
self.on_muc_resume()
def _monitor_stale(self):
self.on_muc_stale()
def _monitor_fresh(self):
self.on_muc_fresh()
def _monitor_exited(self):
if self.muc_autorejoin:
self._service._cycle(self)
else:
self._disconnect()
def _match_tracker(self, message):
try:
tracker = self._tracking_by_id[message.id_]
except KeyError:
if (self._this_occupant is not None and
message.from_ == self._this_occupant.conversation_jid):
key = _extract_one_pair(message.body)
self._service.logger.debug("trying to match by body: %r",
key)
try:
trackers = self._tracking_by_body[key]
except KeyError:
alt_key = (None, key[1])
try:
trackers = self._tracking_by_body[alt_key]
except KeyError:
trackers = None
else:
self._service.logger.debug("found tracker by body")
else:
self._service.logger.debug(
"can’t match by body because of sender mismatch"
)
trackers = None
if not trackers:
tracker = None
else:
tracker = trackers[0]
else:
self._service.logger.debug("found tracker by ID")
if tracker is None:
return False
id_key, body_key = self._tracking_metadata.pop(tracker)
del self._tracking_by_id[id_key]
# remove tracker from list and delete list map entry if empty
trackers = self._tracking_by_body[body_key]
del trackers[0]
if not trackers:
del self._tracking_by_body[body_key]
try:
tracker._set_state(
aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT,
message,
)
except ValueError:
# this can happen if another implementation was faster with
# changing the state than we were.
pass
return True
def _handle_message(self, message, peer, sent, source):
self._service.logger.debug("%s: inbound message %r",
self._mucjid,
message)
self._monitor.enable()
self._monitor.reset()
if self._state == RoomState.HISTORY and not message.xep0203_delay:
# WORKAROUND: prosody#1053; AFFECTS: <= 0.9.12, <= 0.10
self._service.logger.debug(
"%s: received un-delayed message during history replay: "
"assuming that server is buggy and replay is over.",
self._mucjid,
)
self._enter_active_state()
if not sent:
if self._match_tracker(message):
return
if (self._this_occupant and
self._this_occupant._conversation_jid == message.from_):
occupant = self._this_occupant
else:
if message.from_.resource is None:
occupant = self._service_member
else:
occupant = self._occupant_info.get(message.from_, None)
if (self._state == RoomState.HISTORY and
not sent and
message.from_.resource is not None):
if (message.xep0045_muc_user and
message.xep0045_muc_user.items):
item = message.xep0045_muc_user.items[0]
jid = item.bare_jid
affiliation = item.affiliation or None
role = item.role or None
else:
jid = None
affiliation = None
role = None
occupant = self._history_replay_occupants.get(jid, occupant)
if (not occupant or
occupant.direct_jid is None or
occupant.direct_jid != jid):
occupant = Occupant(message.from_, False,
presence_state=aioxmpp.PresenceState(),
jid=jid,
affiliation=affiliation,
role=role)
if jid is not None:
self._history_replay_occupants[jid] = occupant
elif occupant is None:
occupant = Occupant(message.from_, False,
presence_state=aioxmpp.PresenceState())
if not message.body and message.subject:
self._subject = aioxmpp.structs.LanguageMap(message.subject)
self._subject_setter = message.from_.resource
self.on_topic_changed(
occupant,
self._subject,
muc_nick=message.from_.resource,
)
self._enter_active_state()
elif message.body:
if occupant is not None and occupant == self._this_occupant:
tracker = aioxmpp.tracking.MessageTracker()
tracker._set_state(
aioxmpp.tracking.MessageState.DELIVERED_TO_RECIPIENT
)
tracker.close()
else:
tracker = None
self.on_message(
message,
occupant,
source,
tracker=tracker,
)
def _diff_presence(self, stanza, info, existing):
if (not info.presence_state.available and
muc_xso.StatusCode.NICKNAME_CHANGE in
stanza.xep0045_muc_user.status_codes):
return (
_OccupantDiffClass.NICK_CHANGED,
(
stanza.xep0045_muc_user.items[0].nick,
)
)
result = (_OccupantDiffClass.UNIMPORTANT, None)
to_emit = []
try:
reason = stanza.xep0045_muc_user.items[0].reason
actor = stanza.xep0045_muc_user.items[0].actor
except IndexError:
reason = None
actor = None
if not info.presence_state.available:
status_codes = stanza.xep0045_muc_user.status_codes
mode = LeaveMode.NORMAL
if muc_xso.StatusCode.REMOVED_ERROR in status_codes:
mode = LeaveMode.ERROR
elif muc_xso.StatusCode.REMOVED_KICKED in status_codes:
mode = LeaveMode.KICKED
elif muc_xso.StatusCode.REMOVED_BANNED in status_codes:
mode = LeaveMode.BANNED
elif muc_xso.StatusCode.REMOVED_AFFILIATION_CHANGE in status_codes:
mode = LeaveMode.AFFILIATION_CHANGE
elif (muc_xso.StatusCode.REMOVED_NONMEMBER_IN_MEMBERS_ONLY
in status_codes):
mode = LeaveMode.MODERATION_CHANGE
elif muc_xso.StatusCode.REMOVED_SERVICE_SHUTDOWN in status_codes:
mode = LeaveMode.SYSTEM_SHUTDOWN
result = (
_OccupantDiffClass.LEFT,
(
mode,
actor,
reason,
)
)
else:
to_emit.append((self.on_presence_changed,
(existing, None, stanza),
{}))
if existing.role != info.role:
to_emit.append((
self.on_muc_role_changed,
(
stanza,
existing,
),
{
"actor": actor,
"reason": reason,
"status_codes": stanza.xep0045_muc_user.status_codes,
},
))
if existing.affiliation != info.affiliation:
to_emit.append((
self.on_muc_affiliation_changed,
(
stanza,
existing,
),
{
"actor": actor,
"reason": reason,
"status_codes": stanza.xep0045_muc_user.status_codes,
},
))
if to_emit:
existing.update(info)
for signal, args, kwargs in to_emit:
signal(*args, **kwargs)
return result
def _handle_self_presence(self, stanza):
info = Occupant.from_presence(stanza, True)
self._monitor.ping_address = stanza.from_
if not self._active:
if stanza.type_ == aioxmpp.structs.PresenceType.UNAVAILABLE:
self._service.logger.debug(
"%s: not active, and received unavailable ... "
"is this a reconnect?",
self._mucjid,
)
return
self._service.logger.debug("%s: not active, configuring",
self._mucjid)
self._this_occupant = info
self._joined = True
self._active = True
self._state = RoomState.HISTORY
self.on_muc_enter(
stanza, info,
muc_status_codes=frozenset(
stanza.xep0045_muc_user.status_codes
)
)
self.on_enter()
return
existing = self._this_occupant
mode, data = self._diff_presence(stanza, info, existing)
if mode == _OccupantDiffClass.NICK_CHANGED:
new_nick, = data
old_nick = existing.nick
self._service.logger.debug("%s: nick changed: %r -> %r",
self._mucjid,
old_nick,
new_nick)
existing._conversation_jid = existing.conversation_jid.replace(
resource=new_nick
)
self.on_nick_changed(existing, old_nick, new_nick)
elif mode == _OccupantDiffClass.LEFT:
mode, actor, reason = data
self._service.logger.debug("%s: we left the MUC. reason=%r",
self._mucjid,
reason)
existing.update(info)
self._monitor.disable()
self.on_exit(muc_leave_mode=mode,
muc_actor=actor,
muc_reason=reason,
muc_status_codes=stanza.xep0045_muc_user.status_codes)
self._joined = False
self._active = False
def _inbound_muc_user_presence(self, stanza):
self._service.logger.debug("%s: inbound muc user presence %r",
self._mucjid,
stanza)
self._monitor.enable()
self._monitor.reset()
if stanza.from_.is_bare:
self._service.logger.debug(
"received muc user presence from bare JID %s. ignoring.",
stanza.from_,
)
return
if self._state == RoomState.HISTORY:
# WORKAROUND: prosody#1053; AFFECTS: <= 0.9.12, <= 0.10
self._service.logger.debug(
"%s: received presence during history replay: "
"assuming that server is buggy and replay is over.",
self._mucjid,
)
self._enter_active_state()
if (muc_xso.StatusCode.SELF in stanza.xep0045_muc_user.status_codes or
(self._this_occupant is not None and
self._this_occupant.conversation_jid == stanza.from_)):
self._service.logger.debug("%s: is self-presence",
self._mucjid)
self._handle_self_presence(stanza)
return
info = Occupant.from_presence(stanza, False)
try:
existing = self._occupant_info[info.conversation_jid]
except KeyError:
if stanza.type_ == aioxmpp.structs.PresenceType.UNAVAILABLE:
self._service.logger.debug(
"received unavailable presence from unknown occupant %r."
" ignoring.",
stanza.from_,
)
return
self._occupant_info[info.conversation_jid] = info
self.on_join(info)
return
mode, data = self._diff_presence(stanza, info, existing)
if mode == _OccupantDiffClass.NICK_CHANGED:
new_nick, = data
old_nick = existing.nick
del self._occupant_info[existing.conversation_jid]
existing._conversation_jid = existing.conversation_jid.replace(
resource=new_nick
)
self._occupant_info[existing.conversation_jid] = existing
self.on_nick_changed(existing, old_nick, new_nick)
elif mode == _OccupantDiffClass.LEFT:
mode, actor, reason = data
existing.update(info)
self.on_leave(
existing,
muc_leave_mode=mode,
muc_actor=actor,
muc_reason=reason,
muc_status_codes=stanza.xep0045_muc_user.status_codes
)
del self._occupant_info[existing.conversation_jid]
def _handle_role_request(self, form):
def submit(fut):
data_xso = fut.result()
msg = aioxmpp.Message(
to=self.jid,
type_=aioxmpp.MessageType.NORMAL,
)
msg.xep0004_data.append(data_xso)
self._service.client.enqueue(msg)
fut = asyncio.Future()
fut.add_done_callback(submit)
self.on_muc_role_request(form, fut)
def send_message(self, msg):
"""
Send a message to the MUC.
:param msg: The message to send.
:type msg: :class:`aioxmpp.Message`
:return: The stanza token of the message.
:rtype: :class:`~aioxmpp.stream.StanzaToken`
There is no need to set the address attributes or the type of the
message correctly; those will be overridden by this method to conform
to the requirements of a message to the MUC. Other attributes are left
untouched (except that :meth:`~.StanzaBase.autoset_id` is called) and
can be used as desired for the message.
.. seealso::
:meth:`.AbstractConversation.send_message` for the full interface
specification.
"""
msg.type_ = aioxmpp.MessageType.GROUPCHAT
msg.to = self._mucjid
# see https://mail.jabber.org/pipermail/standards/2017-January/032048.html # NOQA
# for a full discussion on the rationale for this.
# TL;DR: we want to help entities to discover that a message is related
# to a MUC.
msg.xep0045_muc_user = muc_xso.UserExt()
result = self.service.client.enqueue(msg)
return result
def _tracker_closed(self, tracker):
try:
id_key, body_key = self._tracking_metadata[tracker]
except KeyError:
return
self._tracking_by_id.pop(id_key, None)
self._tracking_by_body.pop(body_key, None)
def send_message_tracked(self, msg):
"""
Send a message to the MUC with tracking.
:param msg: The message to send.
:type msg: :class:`aioxmpp.Message`
.. warning::
Please read :ref:`api-tracking-memory`. This is especially relevant
for MUCs because tracking is not guaranteed to work due to how
:xep:`45` is written. It will work in many cases, probably in all
cases you test during development, but it may fail to work for some
individual messages and it may fail to work consistently for some
services. See the implementation details below for reasons.
The message is tracked and is considered
:attr:`~.MessageState.DELIVERED_TO_RECIPIENT` when it is reflected back
to us by the MUC service. The reflected message is then available in
the :attr:`~.MessageTracker.response` attribute.
.. note::
Two things:
1. The MUC service may change the contents of the message. An
example of this is the Prosody developer MUC which replaces
messages with more than a few lines with a pastebin link.
2. Reflected messages which are caught by tracking are not emitted
through :meth:`on_message`.
There is no need to set the address attributes or the type of the
message correctly; those will be overridden by this method to conform
to the requirements of a message to the MUC. Other attributes are left
untouched (except that :meth:`~.StanzaBase.autoset_id` is called) and
can be used as desired for the message.
.. warning::
Using :meth:`send_message_tracked` before :meth:`on_join` has
emitted will cause the `member` object in the resulting
:meth:`on_message` event to be :data:`None` (the message will be
delivered just fine).
Using :meth:`send_message_tracked` before history replay is over
will cause the :meth:`on_message` event to be emitted during
history replay, even though everyone else in the MUC will -- of
course -- only see the message after the history.
:meth:`send_message` is not affected by these quirks.
.. seealso::
:meth:`.AbstractConversation.send_message_tracked` for the full
interface specification.
**Implementation details:** Currently, we try to detect reflected
messages using two different criteria. First, if we see a message with
the same message ID (note that message IDs contain 120 bits of entropy)
as the message we sent, we consider it as the reflection. As some MUC
services re-write the message ID in the reflection, as a fallback, we
also consider messages which originate from the correct sender and have
the correct body a reflection.
Obviously, this fails consistently in MUCs which re-write the body and
re-write the ID and randomly if the MUC always re-writes the ID but
only sometimes the body.
"""
msg.type_ = aioxmpp.MessageType.GROUPCHAT
msg.to = self._mucjid
# see https://mail.jabber.org/pipermail/standards/2017-January/032048.html # NOQA
# for a full discussion on the rationale for this.
# TL;DR: we want to help entities to discover that a message is related
# to a MUC.
msg.xep0045_muc_user = muc_xso.UserExt()
msg.autoset_id()
tracking_svc = self.service.dependencies[
aioxmpp.tracking.BasicTrackingService
]
tracker = aioxmpp.tracking.MessageTracker()
id_key = msg.id_
body_key = _extract_one_pair(msg.body)
self._tracking_by_id[id_key] = tracker
self._tracking_metadata[tracker] = (
id_key,
body_key,
)
self._tracking_by_body.setdefault(
body_key,
[]
).append(tracker)
tracker.on_closed.connect(functools.partial(
self._tracker_closed,
tracker,
))
token = tracking_svc.send_tracked(msg, tracker)
self.on_message(
msg,
self._this_occupant,
aioxmpp.im.dispatcher.MessageSource.STREAM,
tracker=tracker,
)
return token, tracker
async def set_nick(self, new_nick):
"""
Change the nick name of the occupant.
:param new_nick: New nickname to use
:type new_nick: :class:`str`
This sends the request to change the nickname and waits for the request
to be sent over the stream.
The nick change may or may not happen, or the service may modify the
nickname; observe the :meth:`on_nick_change` event.
.. seealso::
:meth:`.AbstractConversation.set_nick` for the full interface
specification.
"""
stanza = aioxmpp.Presence(
type_=aioxmpp.PresenceType.AVAILABLE,
to=self._mucjid.replace(resource=new_nick),
)
await self._service.client.send(
stanza
)
async def kick(self, member, reason=None):
"""
Kick an occupant from the MUC.
:param member: The member to kick.
:type member: :class:`Occupant`
:param reason: A reason to show to the members of the conversation
including the kicked member.
:type reason: :class:`str`
:raises aioxmpp.errors.XMPPError: if the server returned an error for
the kick command.
.. seealso::
:meth:`.AbstractConversation.kick` for the full interface
specification.
"""
await self.muc_set_role(
member.nick,
"none",
reason=reason
)
async def muc_set_role(self, nick, role, *, reason=None):
"""
Change the role of an occupant.
:param nick: The nickname of the occupant whose role shall be changed.
:type nick: :class:`str`
:param role: The new role for the occupant.
:type role: :class:`str`
:param reason: An optional reason to show to the occupant (and all
others).
Change the role of an occupant, identified by their `nick`, to the
given new `role`. Optionally, a `reason` for the role change can be
provided.
Setting the different roles require different privilegues of the local
user. The details can be checked in :xep:`0045` and are enforced solely
by the server, not local code.
The coroutine returns when the role change has been acknowledged by the
server. If the server returns an error, an appropriate
:class:`aioxmpp.errors.XMPPError` subclass is raised.
"""
if nick is None:
raise ValueError("nick must not be None")
if role is None:
raise ValueError("role must not be None")
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=self._mucjid
)
iq.payload = muc_xso.AdminQuery(
items=[
muc_xso.AdminItem(nick=nick,
reason=reason,
role=role)
]
)
await self.service.client.send(iq)
async def ban(self, member, reason=None, *, request_kick=True):
"""
Ban an occupant from re-joining the MUC.
:param member: The occupant to ban.
:type member: :class:`Occupant`
:param reason: A reason to show to the members of the conversation
including the banned member.
:type reason: :class:`str`
:param request_kick: A flag indicating that the member should be
removed from the conversation immediately, too.
:type request_kick: :class:`bool`
`request_kick` is supported by MUC, but setting it to false has no
effect: banned members are always immediately kicked.
.. seealso::
:meth:`.AbstractConversation.ban` for the full interface
specification.
"""
if member.direct_jid is None:
raise ValueError(
"cannot ban members whose direct JID is not "
"known")
await self.muc_set_affiliation(
member.direct_jid,
"outcast",
reason=reason
)
async def muc_set_affiliation(self, jid, affiliation, *, reason=None):
"""
Convenience wrapper around :meth:`.MUCClient.set_affiliation`. See
there for details, and consider its `mucjid` argument to be set to
:attr:`mucjid`.
"""
return await self.service.set_affiliation(
self._mucjid,
jid, affiliation,
reason=reason
)
async def set_topic(self, new_topic):
"""
Change the (possibly publicly) visible topic of the conversation.
:param new_topic: The new topic for the conversation.
:type new_topic: :class:`str`
Request to set the subject to `new_topic`. `new_topic` must be a
mapping which maps :class:`~.structs.LanguageTag` tags to strings;
:data:`None` is a valid key.
"""
msg = aioxmpp.stanza.Message(
type_=aioxmpp.structs.MessageType.GROUPCHAT,
to=self._mucjid
)
msg.subject.update(new_topic)
await self.service.client.send(msg)
async def leave(self):
"""
Leave the MUC.
"""
fut = self.on_exit.future()
presence = aioxmpp.stanza.Presence(
type_=aioxmpp.structs.PresenceType.UNAVAILABLE,
to=self._mucjid
)
await self.service.client.send(presence)
await fut
async def muc_request_voice(self):
"""
Request voice (participant role) in the room and wait for the request
to be sent.
The participant role allows occupants to send messages while the room
is in moderated mode.
There is no guarantee that the request will be granted. To detect that
voice has been granted, observe the :meth:`on_role_change` signal.
.. versionadded:: 0.8
"""
msg = aioxmpp.Message(
to=self._mucjid,
type_=aioxmpp.MessageType.NORMAL
)
data = aioxmpp.forms.Data(
aioxmpp.forms.DataType.SUBMIT,
)
data.fields.append(
aioxmpp.forms.Field(
type_=aioxmpp.forms.FieldType.HIDDEN,
var="FORM_TYPE",
values=["http://jabber.org/protocol/muc#request"],
),
)
data.fields.append(
aioxmpp.forms.Field(
type_=aioxmpp.forms.FieldType.LIST_SINGLE,
var="muc#role",
values=["participant"],
)
)
msg.xep0004_data.append(data)
await self.service.client.send(msg)
async def invite(self, address, text=None, *,
mode=aioxmpp.im.InviteMode.DIRECT,
allow_upgrade=False):
if mode == aioxmpp.im.InviteMode.DIRECT:
msg = aioxmpp.Message(
type_=aioxmpp.MessageType.NORMAL,
to=address.bare(),
)
msg.xep0249_direct_invite = muc_xso.DirectInvite(
self.jid,
reason=text,
)
return self.service.client.enqueue(msg), self
if mode == aioxmpp.im.InviteMode.MEDIATED:
invite = muc_xso.Invite()
invite.to = address
invite.reason = text
msg = aioxmpp.Message(
type_=aioxmpp.MessageType.NORMAL,
to=self.jid,
)
msg.xep0045_muc_user = muc_xso.UserExt()
msg.xep0045_muc_user.invites.append(invite)
return self.service.client.enqueue(msg), self
def _connect_to_signal(signal, func):
return signal, signal.connect(func)
class MUCClient(aioxmpp.im.conversation.AbstractConversationService,
aioxmpp.service.Service):
"""
:term:`Conversation Implementation` for Multi-User Chats (:xep:`45`).
.. seealso::
:class:`~.AbstractConversationService`
for useful common signals
This service provides access to Multi-User Chats using the
conversation interface defined by :mod:`aioxmpp.im`.
Client service implementing the a Multi-User Chat client. By loading it
into a client, it is possible to join multi-user chats and implement
interaction with them.
Private Messages into the MUC are not handled by this service. They are
handled by the normal :class:`.p2p.Service`.
.. automethod:: join
Manage rooms:
.. automethod:: get_room_config
.. automethod:: set_room_config
.. automethod:: get_affiliated
.. automethod:: set_affiliation
Global events:
.. signal:: on_muc_invitation(stanza, muc_address, inviter_address, mode, *, password=None, reason=None, **kwargs)
Emits when a MUC invitation has been received.
.. versionadded:: 0.10
:param stanza: The stanza containing the invitation.
:type stanza: :class:`aioxmpp.Message`
:param muc_address: The address of the MUC to which the invitation
points.
:type muc_address: :class:`aioxmpp.JID`
:param inviter_address: The address of the inviter.
:type inviter_address: :class:`aioxmpp.JID` or :data:`None`
:param mode: The type of the invitation.
:type mode: :class:`.im.InviteMode`
:param password: Password for the MUC.
:type password: :class:`str` or :data:`None`
:param reason: Text accompanying the invitation.
:type reason: :class:`str` or :data:`None`
The format of the `inviter_address` depends on the `mode`:
:attr:`~.im.InviteMode.DIRECT`
For direct invitations, the `inviter_address` is the full or bare
JID of the entity which sent the invitation. Usually, this will
be a full JID of a users client.
:attr:`~.im.InviteMode.MEDIATED`
For mediated invitations, the `inviter_address` is either the
occupant JID of the inviting occupant or the real bare or full JID
of the occupant (:xep:`45` leaves it up to the service to decide).
May also be :data:`None`.
.. warning::
Neither invitation type is perfect and has issues. Mediated invites
can easily be spoofed by MUCs (both their intent and the inviter
address) and might be used by spam rooms to trick users into
joining. Direct invites may not reach the recipient due to local
policy, but they allow proper sender attribution.
`inviter_address` values which are not an occupant JID should not
be trusted for mediated invites!
How to deal with this is a policy decision which :mod:`aioxmpp`
can not make for your application.
.. versionchanged:: 0.8
This class was formerly known as :class:`aioxmpp.muc.Service`. It
is still available under that name, but the alias will be removed in
1.0.
.. versionchanged:: 0.9
This class was completely remodeled in 0.9 to conform with the
:class:`aioxmpp.im` interface.
.. versionchanged:: 0.10
This class now conforms to the :class:`~.AbstractConversationService`
interface.
""" # NOQA: E501
ORDER_AFTER = [
aioxmpp.im.dispatcher.IMDispatcher,
aioxmpp.im.service.ConversationService,
aioxmpp.tracking.BasicTrackingService,
aioxmpp.DiscoServer,
]
ORDER_BEFORE = [
aioxmpp.im.p2p.Service,
]
on_muc_invitation = aioxmpp.callbacks.Signal()
direct_invite_feature = aioxmpp.disco.register_feature(
namespaces.xep0249_conference,
)
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._pending_mucs = {}
self._joined_mucs = {}
def _send_join_presence(self, mucjid, history, nick, password):
presence = aioxmpp.stanza.Presence()
presence.to = mucjid.replace(resource=nick)
presence.xep0045_muc = muc_xso.GenericExt()
presence.xep0045_muc.password = password
presence.xep0045_muc.history = history
self.client.enqueue(presence)
@aioxmpp.service.depsignal(aioxmpp.Client, "on_stream_established")
def _stream_established(self):
self.logger.debug("stream established, (re-)connecting to %d mucs",
len(self._pending_mucs))
for muc, fut, nick, history in self._pending_mucs.values():
if muc.muc_joined:
self.logger.debug("%s: resuming", muc.jid)
muc._resume()
self.logger.debug("%s: sending join presence", muc.jid)
self._send_join_presence(muc.jid, history, nick, muc.muc_password)
@aioxmpp.service.depsignal(aioxmpp.Client, "on_stream_destroyed")
def _stream_destroyed(self):
self.logger.debug(
"stream destroyed, preparing autorejoin and cleaning up the others"
)
new_pending = {}
for muc, fut, *more in self._pending_mucs.values():
if not muc.muc_autorejoin:
self.logger.debug(
"%s: pending without autorejoin -> ConnectionError",
muc.jid
)
fut.set_exception(ConnectionError())
else:
self.logger.debug(
"%s: pending with autorejoin -> keeping",
muc.jid
)
new_pending[muc.jid] = (muc, fut) + tuple(more)
self._pending_mucs = new_pending
for muc in list(self._joined_mucs.values()):
if muc.muc_autorejoin:
self.logger.debug(
"%s: connected with autorejoin, suspending and adding to "
"pending",
muc.jid
)
muc._suspend()
self._pending_mucs[muc.jid] = (
muc, None, muc.me.nick, muc_xso.History(
since=datetime.utcnow()
)
)
else:
self.logger.debug(
"%s: connected with autorejoin, disconnecting",
muc.jid
)
muc._disconnect()
self.logger.debug("state now: pending=%r, joined=%r",
self._pending_mucs,
self._joined_mucs)
def _pending_join_done(self, mucjid, room, fut):
try:
fut.result()
except (Exception, asyncio.CancelledError) as exc:
room.on_failure(exc)
if fut.cancelled():
try:
del self._pending_mucs[mucjid]
except KeyError:
pass
unjoin = aioxmpp.stanza.Presence(
to=mucjid,
type_=aioxmpp.structs.PresenceType.UNAVAILABLE,
)
unjoin.xep0045_muc = muc_xso.GenericExt()
self.client.enqueue(unjoin)
def _pending_on_enter(self, presence, occupant, **kwargs):
mucjid = presence.from_.bare()
try:
pending, fut, *_ = self._pending_mucs.pop(mucjid)
except KeyError:
pass # huh
else:
self.logger.debug("%s: pending -> joined",
mucjid)
if fut is not None:
fut.set_result(None)
self._joined_mucs[mucjid] = pending
def _inbound_muc_user_presence(self, stanza):
mucjid = stanza.from_.bare()
try:
muc = self._joined_mucs[mucjid]
except KeyError:
try:
muc, *_ = self._pending_mucs[mucjid]
except KeyError:
return
muc._inbound_muc_user_presence(stanza)
def _inbound_presence_error(self, stanza):
mucjid = stanza.from_.bare()
try:
pending, fut, *_ = self._pending_mucs.pop(mucjid)
except KeyError:
pass
else:
fut.set_exception(stanza.error.to_exception())
@aioxmpp.service.depfilter(
aioxmpp.im.dispatcher.IMDispatcher,
"presence_filter")
def _handle_presence(self, stanza, peer, sent):
if sent:
return stanza
if stanza.xep0045_muc_user is not None:
self._inbound_muc_user_presence(stanza)
return None
if stanza.type_ == aioxmpp.structs.PresenceType.ERROR:
self._inbound_presence_error(stanza)
return None
return stanza
@aioxmpp.service.depfilter(
aioxmpp.im.dispatcher.IMDispatcher,
"message_filter")
def _handle_message(self, message, peer, sent, source):
if message.xep0045_muc_user and message.xep0045_muc_user.invites:
if sent:
return None
invite = message.xep0045_muc_user.invites[0]
if invite.to:
# outbound mediated invite -- we should never be receiving this
# with sent=False
self.logger.debug(
"received outbound mediated invite?! dropping"
)
return None
# mediated invitation
self.on_muc_invitation(
message,
message.from_.bare(),
invite.from_,
aioxmpp.im.InviteMode.MEDIATED,
password=invite.password,
reason=invite.reason,
)
return None
if message.xep0249_direct_invite:
if sent:
return None
invite = message.xep0249_direct_invite
try:
jid = invite.jid
except AttributeError:
self.logger.debug(
"received direct invitation without destination JID; "
"dropping",
)
return None
self.on_muc_invitation(
message,
jid,
message.from_,
aioxmpp.im.InviteMode.DIRECT,
password=invite.password,
reason=invite.reason,
)
return None
if (source == aioxmpp.im.dispatcher.MessageSource.CARBONS
and message.xep0045_muc_user):
return None
mucjid = peer.bare()
try:
muc = self._joined_mucs[mucjid]
except KeyError:
return message
if (message.type_ == aioxmpp.MessageType.NORMAL and not sent and
peer == mucjid):
for form in message.xep0004_data:
if form.get_form_type() != muc_xso.VoiceRequestForm.FORM_TYPE:
continue
form_obj = muc_xso.VoiceRequestForm.from_xso(form)
muc._handle_role_request(form_obj)
return None
if message.type_ != aioxmpp.MessageType.GROUPCHAT:
if muc is not None:
if source == aioxmpp.im.dispatcher.MessageSource.CARBONS:
return None
# tag so that p2p.Service knows what to do
message.xep0045_muc_user = muc_xso.UserExt()
return message
muc._handle_message(
message, peer, sent, source
)
def _muc_exited(self, muc, *args, **kwargs):
try:
del self._joined_mucs[muc.jid]
except KeyError:
_, fut, *_ = self._pending_mucs.pop(muc.jid)
if not fut.done():
fut.set_result(None)
def _cycle(self, room: Room):
try:
room, fut, nick, history = self._pending_mucs[room.jid]
except KeyError:
# the muc is already joined
nick = room.me.nick
# we do not request history for cycle operations; there is no way
# to determine the right amount. this could be changed in the
# future.
history = muc_xso.History()
history.maxchars = 0
history.maxstanzas = 0
unjoin = aioxmpp.stanza.Presence(
type_=aioxmpp.structs.PresenceType.UNAVAILABLE,
to=room.jid.replace(resource=nick),
)
unjoin.xep0045_muc = muc_xso.GenericExt()
self.client.enqueue(unjoin)
room._suspend()
room._resume()
self._send_join_presence(
room.jid,
history,
nick,
room.muc_password,
)
def get_muc(self, mucjid):
try:
return self._joined_mucs[mucjid]
except KeyError:
return self._pending_mucs[mucjid][0]
async def _shutdown(self):
for muc, fut, *_ in self._pending_mucs.values():
muc._disconnect()
fut.set_exception(ConnectionError())
self._pending_mucs.clear()
for muc in list(self._joined_mucs.values()):
muc._disconnect()
self._joined_mucs.clear()
def join(self, mucjid, nick, *,
password=None, history=None, autorejoin=True):
"""
Join a multi-user chat and create a conversation for it.
:param mucjid: The bare JID of the room to join.
:type mucjid: :class:`~aioxmpp.JID`.
:param nick: The nickname to use in the room.
:type nick: :class:`str`
:param password: The password to join the room, if required.
:type password: :class:`str`
:param history: Specification for how much and which history to fetch.
:type history: :class:`.xso.History`
:param autorejoin: Flag to indicate that the MUC should be
automatically rejoined after a disconnect.
:type autorejoin: :class:`bool`
:raises ValueError: if the MUC JID is invalid.
:return: The :term:`Conversation` and a future on the join.
:rtype: tuple of :class:`~.Room` and :class:`asyncio.Future`.
Join a multi-user chat at `mucjid` with `nick`. Return a :class:`Room`
instance which is used to track the MUC locally and a
:class:`aioxmpp.Future` which becomes done when the join succeeded
(with a :data:`None` value) or failed (with an exception).
In addition, the :meth:`~.ConversationService.on_conversation_added`
signal is emitted immediately with the new :class:`Room`.
It is recommended to attach the desired signals to the :class:`Room`
before yielding next (e.g. in a non-deferred event handler to the
:meth:`~.ConversationService.on_conversation_added` signal), to avoid
races with the server. It is guaranteed that no signals are emitted
before the next yield, and thus, it is safe to attach the signals right
after :meth:`join` returned. (This is also the reason why :meth:`join`
is not a coroutine, but instead returns the room and a future to wait
for.)
Any other interaction with the room must go through the :class:`Room`
instance.
If the multi-user chat at `mucjid` is already or currently being
joined, the existing :class:`Room` and future is returned. The `nick`
and other options for the new join are ignored.
If the `mucjid` is not a bare JID, :class:`ValueError` is raised.
`password` may be a string used as password for the MUC. It will be
remembered and stored at the returned :class:`Room` instance.
`history` may be a :class:`History` instance to request a specific
amount of history; otherwise, the server will return a default amount
of history.
If `autorejoin` is true, the MUC will be re-joined after the stream has
been destroyed and re-established. In that case, the service will
request history since the stream destruction and ignore the `history`
object passed here.
If the stream is currently not established, the join is deferred until
the stream is established.
"""
if history is not None and not isinstance(history, muc_xso.History):
raise TypeError("history must be {!s}, got {!r}".format(
muc_xso.History.__name__,
history))
if not mucjid.is_bare:
raise ValueError("MUC JID must be bare")
try:
room, fut, *_ = self._pending_mucs[mucjid]
except KeyError:
pass
else:
return room, fut
try:
room = self._joined_mucs[mucjid]
except KeyError:
pass
else:
fut = asyncio.Future()
fut.set_result(None)
return room, fut
room = Room(self, mucjid)
room.muc_autorejoin = autorejoin
room.muc_password = password
room.on_exit.connect(
functools.partial(
self._muc_exited,
room
)
)
room.on_muc_enter.connect(
self._pending_on_enter,
)
fut = asyncio.Future()
fut.add_done_callback(functools.partial(
self._pending_join_done,
mucjid,
room,
))
self._pending_mucs[mucjid] = room, fut, nick, history
if self.client.established:
self._send_join_presence(mucjid, history, nick, password)
self.on_conversation_new(room)
self.dependencies[
aioxmpp.im.service.ConversationService
]._add_conversation(room)
return room, fut
async def get_affiliated(self, mucjid, affiliation):
"""
Retrieve the list of JIDs with the given affiliation with a MUC.
:param mucjid: The bare JID identifying the MUC.
:type mucjid: :class:`~aioxmpp.JID`
:param affiliation: The affiliation level to query.
:type affiliation: :class:`str`
:raises: :class:`aioxmpp.errors.XMPPAuthError` if the client does not
have sufficient privileges to query affiliations of the given
level.
:return: Collection of JIDs with the given affiliation.
"""
req = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.GET,
to=mucjid,
)
req.payload = muc_xso.AdminQuery(
items=[
muc_xso.AdminItem(affiliation=affiliation),
]
)
resp = await self.client.send(req)
return [
item.jid
for item in resp.items
]
async def set_affiliation(self, mucjid, jid, affiliation, *, reason=None):
"""
Change the affiliation of an entity with a MUC.
:param mucjid: The bare JID identifying the MUC.
:type mucjid: :class:`~aioxmpp.JID`
:param jid: The bare JID of the entity whose affiliation shall be
changed.
:type jid: :class:`~aioxmpp.JID`
:param affiliation: The new affiliation for the entity.
:type affiliation: :class:`str`
:param reason: Optional reason for the affiliation change.
:type reason: :class:`str` or :data:`None`
Change the affiliation of the given `jid` with the MUC identified by
the bare `mucjid` to the given new `affiliation`. Optionally, a
`reason` can be given.
If you are joined in the MUC, :meth:`Room.muc_set_affiliation` may be
more convenient, but it is possible to modify the affiliations of a MUC
without being joined, given sufficient privilegues.
Setting the different affiliations require different privilegues of the
local user. The details can be checked in :xep:`0045` and are enforced
solely by the server, not local code.
The coroutine returns when the change in affiliation has been
acknowledged by the server. If the server returns an error, an
appropriate :class:`aioxmpp.errors.XMPPError` subclass is raised.
"""
if mucjid is None or not mucjid.is_bare:
raise ValueError("mucjid must be bare JID")
if jid is None:
raise ValueError("jid must not be None")
if affiliation is None:
raise ValueError("affiliation must not be None")
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=mucjid
)
iq.payload = muc_xso.AdminQuery(
items=[
muc_xso.AdminItem(jid=jid,
reason=reason,
affiliation=affiliation)
]
)
await self.client.send(iq)
async def get_room_config(self, mucjid):
"""
Query and return the room configuration form for the given MUC.
:param mucjid: JID of the room to query
:type mucjid: bare :class:`~.JID`
:return: data form template for the room configuration
:rtype: :class:`aioxmpp.forms.Data`
.. seealso::
:class:`~.ConfigurationForm`
for a form template to work with the returned form
.. versionadded:: 0.7
"""
if mucjid is None or not mucjid.is_bare:
raise ValueError("mucjid must be bare JID")
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.GET,
to=mucjid,
payload=muc_xso.OwnerQuery(),
)
return (await self.client.send(iq)).form
async def set_room_config(self, mucjid, data):
"""
Set the room configuration using a :xep:`4` data form.
:param mucjid: JID of the room to query
:type mucjid: bare :class:`~.JID`
:param data: Filled-out configuration form
:type data: :class:`aioxmpp.forms.Data`
.. seealso::
:class:`~.ConfigurationForm`
for a form template to generate the required form
A sensible workflow to, for example, set a room to be moderated, could
be this::
form = aioxmpp.muc.ConfigurationForm.from_xso(
(await muc_service.get_room_config(mucjid))
)
form.moderatedroom = True
await muc_service.set_rooom_config(mucjid, form.render_reply())
.. versionadded:: 0.7
"""
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=mucjid,
payload=muc_xso.OwnerQuery(form=data),
)
await self.client.send(iq)
aioxmpp/muc/xso.py 0000664 0000000 0000000 00000045334 14160146213 0014511 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import enum
import aioxmpp.forms
import aioxmpp.stanza
import aioxmpp.stringprep
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0045_muc = "http://jabber.org/protocol/muc"
namespaces.xep0045_muc_user = "http://jabber.org/protocol/muc#user"
namespaces.xep0045_muc_admin = "http://jabber.org/protocol/muc#admin"
namespaces.xep0045_muc_owner = "http://jabber.org/protocol/muc#owner"
namespaces.xep0249_conference = "jabber:x:conference"
class StatusCode(enum.IntEnum):
"""
This integer enumeration (see :class:`enum.IntEnum`) is used for the
status codes defined in :xep:`45`.
Note that members of this enumeration are equal to their respective integer
values, making it ideal for backward- and forward-compatible code and a
replacement for magic numbers.
.. versionadded:: 0.10
Before version 0.10, this enum did not exist and the numeric codes
were used bare. Since this is an :class:`~enum.IntEnum`, it is possible
to use the named enum members and their numeric codes interchangeably.
.. attribute:: NON_ANONYMOUS
:annotation: = 100
Included when entering a room where every user can see every users
real JID.
.. attribute:: AFFILIATION_CHANGE
:annotation: = 101
Included in out-of-band messages informing about affiliation changes.
.. attribute:: SHOWING_UNAVAILABLE
:annotation: = 102
Inform occupants that room now shows unavailable members.
.. attribute:: NOT_SHOWING_UNAVAILABLE
:annotation: = 103
Inform occupants that room now does not show unavailable members.
.. attribute:: CONFIG_NON_PRIVACY_RELATED
:annotation: = 104
Inform occupants that a non-privacy related configuration change has
occurred.
.. attribute:: SELF
:annotation: = 110
Inform that the stanza refers to the addressee themselves.
.. attribute:: CONFIG_ROOM_LOGGING
:annotation: = 170
Inform that the room is now logged.
.. attribute:: CONFIG_NO_ROOM_LOGGING
:annotation: = 171
Inform that the room is not logged anymore.
.. attribute:: CONFIG_NON_ANONYMOUS
:annotation: = 172
Inform that the room is now not anonymous.
.. attribute:: CONFIG_SEMI_ANONYMOUS
:annotation: = 173
Inform that the room is now semi-anonymous.
.. attribute:: CREATED
:annotation: = 201
Inform that the room was created during the join operation.
.. attribute:: REMOVED_BANNED
:annotation: = 301
Inform that the user was banned from the room.
.. attribute:: NICKNAME_CHANGE
:annotation: = 303
Inform about new nickname.
.. attribute:: REMOVED_KICKED
:annotation: = 307
Inform that the occupant was kicked.
.. attribute:: REMOVED_AFFILIATION_CHANGE
:annotation: = 321
Inform that the occupant was removed from the room due to a change in
affiliation.
.. attribute:: REMOVED_NONMEMBER_IN_MEMBERS_ONLY
:annotation: = 322
Inform that the occupant was removed from the room because the room was
changed to members-only and the occupant was not a member.
.. attribute:: REMOVED_SERVICE_SHUTDOWN
:annotation: = 332
Inform that the occupant is being removed because the MUC service is
being shut down.
.. attribute:: REMOVED_ERROR
:annotation: = 333
Inform that the occupant is being removed because there was an error
while communicating with them or their server.
"""
NON_ANONYMOUS = 100
AFFILIATION_CHANGE = 101
SHOWING_UNAVAILABLE = 102
NOT_SHOWING_UNAVAILABLE = 103
CONFIG_NON_PRIVACY_RELATED = 104
SELF = 110
CONFIG_ROOM_LOGGING = 170
CONFIG_NO_ROOM_LOGGING = 171
CONFIG_NON_ANONYMOUS = 172
CONFIG_SEMI_ANONYMOUS = 173
CREATED = 201
REMOVED_BANNED = 301
NICKNAME_CHANGE = 303
REMOVED_KICKED = 307
REMOVED_AFFILIATION_CHANGE = 321
REMOVED_NONMEMBER_IN_MEMBERS_ONLY = 322
REMOVED_SERVICE_SHUTDOWN = 332
REMOVED_ERROR = 333
class History(xso.XSO):
TAG = (namespaces.xep0045_muc, "history")
maxchars = xso.Attr(
"maxchars",
type_=xso.Integer(),
default=None,
)
maxstanzas = xso.Attr(
"maxstanzas",
type_=xso.Integer(),
default=None,
)
seconds = xso.Attr(
"seconds",
type_=xso.Integer(),
default=None,
)
since = xso.Attr(
"since",
type_=xso.DateTime(),
default=None,
)
def __init__(self, *,
maxchars=None, maxstanzas=None, seconds=None, since=None):
super().__init__()
self.maxchars = maxchars
self.maxstanzas = maxstanzas
self.seconds = seconds
self.since = since
class GenericExt(xso.XSO):
TAG = (namespaces.xep0045_muc, "x")
history = xso.Child([History])
password = xso.ChildText(
(namespaces.xep0045_muc, "password"),
default=None
)
aioxmpp.stanza.Presence.xep0045_muc = xso.Child([
GenericExt
])
aioxmpp.stanza.Message.xep0045_muc = xso.Child([
GenericExt
])
class Status(xso.XSO):
TAG = (namespaces.xep0045_muc_user, "status")
code = xso.Attr(
"code",
type_=xso.EnumCDataType(
StatusCode,
xso.Integer(),
allow_coerce=True,
pass_unknown=True,
)
)
def __init__(self, code):
super().__init__()
self.code = code
class StatusCodeList(xso.AbstractElementType):
def unpack(self, item):
return item.code
def pack(self, code):
item = Status(code)
return item
def get_xso_types(self):
return [Status]
class DestroyNotification(xso.XSO):
TAG = (namespaces.xep0045_muc_user, "destroy")
reason = xso.ChildText(
(namespaces.xep0045_muc_user, "reason"),
default=None
)
jid = xso.Attr(
"jid",
type_=xso.JID(),
default=None
)
class Decline(xso.XSO):
TAG = (namespaces.xep0045_muc_user, "decline")
from_ = xso.Attr(
"from",
type_=xso.JID(),
default=None
)
to = xso.Attr(
"to",
type_=xso.JID(),
default=None
)
reason = xso.ChildText(
(namespaces.xep0045_muc_user, "reason"),
default=None
)
class Invite(xso.XSO):
TAG = (namespaces.xep0045_muc_user, "invite")
from_ = xso.Attr(
"from",
type_=xso.JID(),
default=None
)
to = xso.Attr(
"to",
type_=xso.JID(),
default=None
)
reason = xso.ChildText(
(namespaces.xep0045_muc_user, "reason"),
default=None
)
password = xso.ChildText(
(namespaces.xep0045_muc_user, "password"),
default=None
)
class ActorBase(xso.XSO):
jid = xso.Attr(
"jid",
type_=xso.JID(),
default=None,
)
nick = xso.Attr(
"nick",
type_=xso.String(aioxmpp.stringprep.resourceprep),
default=None
)
class ItemBase(xso.XSO):
affiliation = xso.Attr(
"affiliation",
validator=xso.RestrictToSet({
"admin",
"member",
"none",
"outcast",
"owner",
None,
}),
validate=xso.ValidateMode.ALWAYS,
default=None,
)
jid = xso.Attr(
"jid",
type_=xso.JID(),
default=None,
)
nick = xso.Attr(
"nick",
type_=xso.String(aioxmpp.stringprep.resourceprep),
default=None
)
role = xso.Attr(
"role",
validator=xso.RestrictToSet({
"moderator",
"none",
"participant",
"visitor",
None,
}),
validate=xso.ValidateMode.ALWAYS,
default=None,
)
def __init__(self,
affiliation=None,
jid=None,
nick=None,
role=None,
reason=None):
super().__init__()
self.affiliation = affiliation
self.jid = jid
self.nick = nick
self.role = role
self.reason = reason
@property
def bare_jid(self):
"""
Return the bare jid of the item or :data:`None` if no JID is
given.
Use this to access the jid unless you really want to know the
resource. Usually the information given by the resource is
meaningless (the resource is randomly picked by the server).
"""
if self.jid:
return self.jid.bare()
else:
return None
class UserActor(ActorBase):
TAG = (namespaces.xep0045_muc_user, "actor")
class Continue(xso.XSO):
TAG = (namespaces.xep0045_muc_user, "continue")
thread = xso.Attr(
"thread",
type_=aioxmpp.stanza.Thread.identifier.type_,
default=None,
)
class UserItem(ItemBase):
TAG = (namespaces.xep0045_muc_user, "item")
actor = xso.Child([UserActor])
continue_ = xso.Child([Continue])
reason = xso.ChildText(
(namespaces.xep0045_muc_user, "reason"),
default=None
)
class UserExt(xso.XSO):
TAG = (namespaces.xep0045_muc_user, "x")
status_codes = xso.ChildValueList(
StatusCodeList(),
container_type=set
)
destroy = xso.Child([DestroyNotification])
decline = xso.Child([Decline])
invites = xso.ChildList([Invite])
items = xso.ChildList([UserItem])
password = xso.ChildText(
(namespaces.xep0045_muc_user, "password"),
default=None
)
def __init__(self,
status_codes=[],
destroy=None,
decline=None,
invites=[],
items=[],
password=None):
super().__init__()
self.status_codes.update(status_codes)
self.destroy = destroy
self.decline = decline
self.invites.extend(invites)
self.items.extend(items)
self.password = password
aioxmpp.stanza.Presence.xep0045_muc_user = xso.Child([
UserExt
])
aioxmpp.stanza.Message.xep0045_muc_user = xso.Child([
UserExt
])
class AdminActor(ActorBase):
TAG = (namespaces.xep0045_muc_admin, "actor")
class AdminItem(ItemBase):
TAG = (namespaces.xep0045_muc_admin, "item")
actor = xso.Child([AdminActor])
continue_ = xso.Child([Continue])
reason = xso.ChildText(
(namespaces.xep0045_muc_admin, "reason"),
default=None
)
@aioxmpp.stanza.IQ.as_payload_class
class AdminQuery(xso.XSO):
TAG = (namespaces.xep0045_muc_admin, "query")
items = xso.ChildList([AdminItem])
def __init__(self, *, items=[]):
super().__init__()
self.items[:] = items
class DestroyRequest(xso.XSO):
TAG = (namespaces.xep0045_muc_owner, "destroy")
reason = xso.ChildText(
(namespaces.xep0045_muc_owner, "reason"),
default=None
)
password = xso.ChildText(
(namespaces.xep0045_muc_owner, "password"),
default=None
)
jid = xso.Attr(
"jid",
type_=xso.JID(),
default=None
)
@aioxmpp.stanza.IQ.as_payload_class
class OwnerQuery(xso.XSO):
TAG = (namespaces.xep0045_muc_owner, "query")
destroy = xso.Child([DestroyRequest])
form = xso.Child([aioxmpp.forms.Data])
def __init__(self, *, form=None, destroy=None):
super().__init__()
self.form = form
self.destroy = destroy
class DirectInvite(xso.XSO):
TAG = namespaces.xep0249_conference, "x"
# JEP-0045 v1.19 §6.7 allowed a mediated(!) invitation to contain a
# (what is now) DirectInvite payload where the reason is included as
# text (and not as attribute).
#
# Some servers still emit this for compatibility. We ignore that.
_ = xso.Text(default=None)
jid = xso.Attr(
"jid",
type_=xso.JID(),
)
reason = xso.Attr(
"reason",
default=None,
)
password = xso.Attr(
"password",
default=None,
)
continue_ = xso.Attr(
"continue",
type_=xso.Bool(),
default=False,
)
thread = xso.Attr(
"thread",
default=None,
)
def __init__(self, jid, *,
reason=None,
password=None,
continue_=False,
thread=None):
super().__init__()
self.jid = jid
self.reason = reason
self.password = password
self.continue_ = continue_
self.thread = thread
aioxmpp.Message.xep0249_direct_invite = xso.Child([DirectInvite])
class ConfigurationForm(aioxmpp.forms.Form):
"""
This is a :xep:`4` form template (see :mod:`aioxmpp.forms`) for MUC
configuration forms.
The attribute documentation is auto-generated from :xep:`45`; see there for
details on the semantics of each field.
.. versionadded:: 0.7
"""
FORM_TYPE = 'http://jabber.org/protocol/muc#roomconfig'
maxhistoryfetch = aioxmpp.forms.TextSingle(
var='muc#maxhistoryfetch',
label='Maximum Number of History Messages Returned by Room'
)
allowpm = aioxmpp.forms.ListSingle(
var='muc#roomconfig_allowpm',
label='Roles that May Send Private Messages'
)
allowinvites = aioxmpp.forms.Boolean(
var='muc#roomconfig_allowinvites',
label='Whether to Allow Occupants to Invite Others'
)
changesubject = aioxmpp.forms.Boolean(
var='muc#roomconfig_changesubject',
label='Whether to Allow Occupants to Change Subject'
)
enablelogging = aioxmpp.forms.Boolean(
var='muc#roomconfig_enablelogging',
label='Whether to Enable Public Logging of Room Conversations'
)
getmemberlist = aioxmpp.forms.ListMulti(
var='muc#roomconfig_getmemberlist',
label='Roles and Affiliations that May Retrieve Member List'
)
lang = aioxmpp.forms.TextSingle(
var='muc#roomconfig_lang',
label='Natural Language for Room Discussions'
)
pubsub = aioxmpp.forms.TextSingle(
var='muc#roomconfig_pubsub',
label='XMPP URI of Associated Publish-Subscribe Node'
)
maxusers = aioxmpp.forms.ListSingle(
var='muc#roomconfig_maxusers',
label='Maximum Number of Room Occupants'
)
membersonly = aioxmpp.forms.Boolean(
var='muc#roomconfig_membersonly',
label='Whether to Make Room Members-Only'
)
moderatedroom = aioxmpp.forms.Boolean(
var='muc#roomconfig_moderatedroom',
label='Whether to Make Room Moderated'
)
passwordprotectedroom = aioxmpp.forms.Boolean(
var='muc#roomconfig_passwordprotectedroom',
label='Whether a Password is Required to Enter'
)
persistentroom = aioxmpp.forms.Boolean(
var='muc#roomconfig_persistentroom',
label='Whether to Make Room Persistent'
)
presencebroadcast = aioxmpp.forms.ListMulti(
var='muc#roomconfig_presencebroadcast',
label='Roles for which Presence is Broadcasted'
)
publicroom = aioxmpp.forms.Boolean(
var='muc#roomconfig_publicroom',
label='Whether to Allow Public Searching for Room'
)
roomadmins = aioxmpp.forms.JIDMulti(
var='muc#roomconfig_roomadmins',
label='Full List of Room Admins'
)
roomdesc = aioxmpp.forms.TextSingle(
var='muc#roomconfig_roomdesc',
label='Short Description of Room'
)
roomname = aioxmpp.forms.TextSingle(
var='muc#roomconfig_roomname',
label='Natural-Language Room Name'
)
roomowners = aioxmpp.forms.JIDMulti(
var='muc#roomconfig_roomowners',
label='Full List of Room Owners'
)
roomsecret = aioxmpp.forms.TextPrivate(
var='muc#roomconfig_roomsecret',
label='The Room Password'
)
whois = aioxmpp.forms.ListSingle(
var='muc#roomconfig_whois',
label='Affiliations that May Discover Real JIDs of Occupants'
)
class InfoForm(aioxmpp.forms.Form):
FORM_TYPE = 'http://jabber.org/protocol/muc#roominfo'
maxhistoryfetch = aioxmpp.forms.TextSingle(
var='muc#maxhistoryfetch',
label='Maximum Number of History Messages Returned by Room'
)
contactjid = aioxmpp.forms.JIDMulti(
var='muc#roominfo_contactjid',
label='Contact Addresses (normally, room owner or owners)'
)
description = aioxmpp.forms.TextSingle(
var='muc#roominfo_description',
label='Short Description of Room'
)
lang = aioxmpp.forms.TextSingle(
var='muc#roominfo_lang',
label='Natural Language for Room Discussions'
)
ldapgroup = aioxmpp.forms.TextSingle(
var='muc#roominfo_ldapgroup',
label='An associated LDAP group that defines room membership; this '
'should be an LDAP Distinguished Name according to an '
'implementation-specific or deployment-specific definition of a group.'
)
logs = aioxmpp.forms.TextSingle(
var='muc#roominfo_logs',
label='URL for Archived Discussion Logs'
)
occupants = aioxmpp.forms.TextSingle(
var='muc#roominfo_occupants',
label='Current Number of Occupants in Room'
)
subject = aioxmpp.forms.TextSingle(
var='muc#roominfo_subject',
label='Current Discussion Topic'
)
subjectmod = aioxmpp.forms.Boolean(
var='muc#roominfo_subjectmod',
label='The room subject can be modified by participants'
)
class VoiceRequestForm(aioxmpp.forms.Form):
FORM_TYPE = 'http://jabber.org/protocol/muc#request'
role = aioxmpp.forms.ListSingle(
var='muc#role',
label='Requested role'
)
jid = aioxmpp.forms.JIDSingle(
var='muc#jid',
label='User ID'
)
roomnick = aioxmpp.forms.TextSingle(
var='muc#roomnick',
label='Room Nickname'
)
request_allow = aioxmpp.forms.Boolean(
var='muc#request_allow',
label='Whether to grant voice'
)
aioxmpp/network.py 0000664 0000000 0000000 00000035313 14160146213 0014601 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: network.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.network` --- DNS resolution utilities
####################################################
This module uses :mod:`dns` to handle DNS queries.
.. versionchanged:: 0.5.4
The module was completely rewritten in 0.5.4. The documented API stayed
mostly the same though.
Configure the resolver
======================
.. versionadded:: 0.5.4
The whole thread-local resolver thing was added in 0.5.4. This includes the
magic to re-configure the used resolver when a query fails.
The module uses a thread-local resolver instance. It can be accessed using
:func:`get_resolver`. Re-read of the system-wide resolver configuration can be
forced by calling :func:`reconfigure_resolver`. To configure a custom resolver
instance, use :func:`set_resolver`.
By setting a custom resolver instance, the facilities which *automatically*
reconfigure the resolver whenever DNS timeouts occur are disabled.
.. note::
Currently, there is no way to set a resolver per XMPP client. If such a way
is desired, feel free to open a bug against :mod:`aioxmpp`. I cannot really
imagine such a situation, but if you encounter one, please let me know.
.. autofunction:: get_resolver
.. autofunction:: reconfigure_resolver
.. autofunction:: set_resolver
Querying records
================
In addition to using the :class:`dns.resolver.Resolver` instance returned by
:func:`get_resolver`, one can also use :func:`repeated_query`. The latter takes
care of re-trying the query up to a configurable amount of times. It will also
automatically call :func:`reconfigure_resolver` (unless a custom resolver has
been set) if a timeout occurs and switch to TCP if problems persist.
.. autofunction:: repeated_query
SRV records
===========
.. autofunction:: find_xmpp_host_addr
.. autofunction:: lookup_srv
.. autofunction:: group_and_order_srv_records
"""
import asyncio
import functools
import itertools
import logging
import random
import threading
import dns
import dns.flags
import dns.resolver
logger = logging.getLogger(__name__)
_state = threading.local()
class ValidationError(Exception):
pass
def get_resolver():
"""
Return the thread-local :class:`dns.resolver.Resolver` instance used by
:mod:`aioxmpp`.
"""
global _state
if not hasattr(_state, "resolver"):
reconfigure_resolver()
return _state.resolver
class DummyResolver:
def set_flags(self, *args, **kwargs):
# noop
pass
def query(self, *args, **kwargs):
raise dns.resolver.NoAnswer
def reconfigure_resolver():
"""
Reset the resolver configured for this thread to a fresh instance. This
essentially re-reads the system-wide resolver configuration.
If a custom resolver has been set using :func:`set_resolver`, the flag
indicating that no automatic re-configuration shall take place is cleared.
"""
global _state
try:
_state.resolver = dns.resolver.Resolver()
except dns.resolver.NoResolverConfiguration:
_state.resolver = DummyResolver()
_state.overridden_resolver = False
def set_resolver(resolver):
"""
Replace the current thread-local resolver (which can be accessed using
:func:`get_resolver`) with `resolver`.
This also sets an internal flag which prohibits the automatic calling of
:func:`reconfigure_resolver` from :func:`repeated_query`. To re-allow
automatic reconfiguration, call :func:`reconfigure_resolver`.
"""
global _state
_state.resolver = resolver
_state.overridden_resolver = True
async def repeated_query(qname, rdtype,
nattempts=None,
resolver=None,
require_ad=False,
executor=None):
"""
Repeatedly fire a DNS query until either the number of allowed attempts
(`nattempts`) is exceeded or a non-error result is returned (NXDOMAIN is
a non-error result).
If `nattempts` is :data:`None`, it is set to 3 if `resolver` is
:data:`None` and to 2 otherwise. This way, no query is made without a
possible change to a local parameter. (When using the thread-local
resolver, it will be re-configured after the first failed query and after
the second failed query, TCP is used. With a fixed resolver, TCP is used
after the first failed query.)
`qname` must be the (IDNA encoded, as :class:`bytes`) name to query,
`rdtype` the record type to query for. If `resolver` is not :data:`None`,
it must be a DNSPython :class:`dns.resolver.Resolver` instance; if it is
:data:`None`, the resolver obtained from :func:`get_resolver` is used.
If `require_ad` is :data:`True`, the peer resolver is asked to do DNSSEC
validation and if the AD flag is missing in the response,
:class:`ValueError` is raised. If `require_ad` is :data:`False`, the
resolver is asked to do DNSSEC validation nevertheless, but missing
validation (in contrast to failed validation) is not an error.
.. note::
This function modifies the flags of the `resolver` instance, no matter
if it uses the thread-local resolver instance or the resolver passed as
an argument.
If the first query fails and `resolver` is :data:`None` and the
thread-local resolver has not been overridden with :func:`set_resolver`,
:func:`reconfigure_resolver` is called and the query is re-attempted
immediately.
If the next query after reconfiguration of the resolver (if the
preconditions for resolver reconfigurations are not met, this applies to
the first failing query), :func:`repeated_query` switches to TCP.
If no result is received before the number of allowed attempts is exceeded,
:class:`TimeoutError` is raised.
Return the result set or :data:`None` if the domain does not exist.
This is a coroutine; the query is executed in an `executor` using the
:meth:`asyncio.BaseEventLoop.run_in_executor` of the current event loop. By
default, the default executor provided by the event loop is used, but it
can be overridden using the `executor` argument.
If the used resolver raises :class:`dns.resolver.NoNameservers`
(semantically, that no nameserver was able to answer the request), this
function suspects that DNSSEC validation failed, as responding with
SERVFAIL is what unbound does. To test that case, a simple check is made:
the query is repeated, but with a flag set which indicates that we would
like to do the validation ourselves. If that query succeeds, we assume that
the error is in fact due to DNSSEC validation failure and raise
:class:`ValidationError`. Otherwise, the answer is discarded and the
:class:`~dns.resolver.NoNameservers` exception is treated as normal
timeout. If the exception re-occurs in the second query, it is re-raised,
as it indicates a serious configuration problem.
"""
global _state
loop = asyncio.get_event_loop()
# tlr = thread-local resolver
use_tlr = False
if resolver is None:
resolver = get_resolver()
use_tlr = not _state.overridden_resolver
if nattempts is None:
if use_tlr:
nattempts = 3
else:
nattempts = 2
if nattempts <= 0:
raise ValueError("query cannot succeed with non-positive amount "
"of attempts")
qname = qname.decode("ascii")
def handle_timeout():
nonlocal use_tlr, resolver, use_tcp
if use_tlr and i == 0:
reconfigure_resolver()
resolver = get_resolver()
else:
use_tcp = True
use_tcp = False
for i in range(nattempts):
resolver.set_flags(dns.flags.RD | dns.flags.AD)
try:
answer = await loop.run_in_executor(
executor,
functools.partial(
resolver.query,
qname,
rdtype,
tcp=use_tcp
)
)
if require_ad and not (answer.response.flags & dns.flags.AD):
raise ValueError("DNSSEC validation not available")
except (TimeoutError, dns.resolver.Timeout):
handle_timeout()
continue
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return None
except (dns.resolver.NoNameservers):
# make sure we have the correct config
if use_tlr and i == 0:
reconfigure_resolver()
resolver = get_resolver()
continue
resolver.set_flags(dns.flags.RD | dns.flags.AD | dns.flags.CD)
try:
await loop.run_in_executor(
executor,
functools.partial(
resolver.query,
qname,
rdtype,
tcp=use_tcp,
raise_on_no_answer=False
))
except (dns.resolver.Timeout, TimeoutError):
handle_timeout()
continue
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
pass
raise ValidationError(
"nameserver error, most likely DNSSEC validation failed",
)
break
else:
raise TimeoutError()
return answer
async def lookup_srv(domain: bytes, service: str, transport: str = "tcp",
**kwargs):
"""
Query the DNS for SRV records describing how the given `service` over the
given `transport` is implemented for the given `domain`. `domain` must be
an IDNA-encoded :class:`bytes` object; `service` must be a normal
:class:`str`.
Keyword arguments are passed to :func:`repeated_query`.
Return a list of tuples ``(prio, weight, (hostname, port))``, where
`hostname` is a IDNA-encoded :class:`bytes` object containing the hostname
obtained from the SRV record. The other fields are also as obtained from
the SRV records. The trailing dot is stripped from the `hostname`.
If the DNS query returns an empty result, :data:`None` is returned. If any
of the found SRV records has the root zone (``.``) as `hostname`, this
indicates that the service is not available at the given `domain` and
:class:`ValueError` is raised.
"""
record = b".".join([
b"_" + service.encode("ascii"),
b"_" + transport.encode("ascii"),
domain])
answer = await repeated_query(
record,
dns.rdatatype.SRV,
**kwargs)
if answer is None:
return None
items = [
(rec.priority, rec.weight, (str(rec.target), rec.port))
for rec in answer
]
for i, (prio, weight, (host, port)) in enumerate(items):
if host == ".":
raise ValueError(
"protocol {!r} over {!r} not supported at {!r}".format(
service,
transport,
domain
)
)
items[i] = (prio, weight, (
host.rstrip(".").encode("ascii"),
port))
return items
async def lookup_tlsa(hostname, port, transport="tcp", require_ad=True,
**kwargs):
"""
Query the DNS for TLSA records describing the certificates and/or keys to
expect when contacting `hostname` at the given `port` over the given
`transport`. `hostname` must be an IDNA-encoded :class:`bytes` object.
The keyword arguments are passed to :func:`repeated_query`; `require_ad`
defaults to :data:`True` here.
Return a list of tuples ``(usage, selector, mtype, cert)`` which contains
the information from the TLSA records.
If no data is returned by the query, :data:`None` is returned instead.
"""
record = b".".join([
b"_" + str(port).encode("ascii"),
b"_" + transport.encode("ascii"),
hostname
])
answer = await repeated_query(
record,
dns.rdatatype.TLSA,
require_ad=require_ad,
**kwargs)
if answer is None:
return None
items = [
(rec.usage, rec.selector, rec.mtype, rec.cert)
for rec in answer
]
return items
def group_and_order_srv_records(all_records, rng=None):
"""
Order a list of SRV record information (as returned by :func:`lookup_srv`)
and group and order them as specified by the RFC.
Return an iterable, yielding each ``(hostname, port)`` tuple inside the
SRV records in the order specified by the RFC. For hosts with the same
priority, the given `rng` implementation is used (if none is given, the
:mod:`random` module is used).
"""
rng = rng or random
all_records.sort(key=lambda x: x[:2])
for priority, records in itertools.groupby(
all_records,
lambda x: x[0]):
records = list(records)
total_weight = sum(
weight
for _, weight, _ in records)
while records:
if len(records) == 1:
yield records[0][-1]
break
value = rng.randint(0, total_weight)
running_weight_sum = 0
for i, (_, weight, addr) in enumerate(records):
running_weight_sum += weight
if running_weight_sum >= value:
yield addr
del records[i]
total_weight -= weight
break
async def find_xmpp_host_addr(loop, domain, attempts=3):
domain = domain.encode("IDNA")
items = await lookup_srv(
service="xmpp-client",
domain=domain,
nattempts=attempts
)
if items is not None:
return items
return [(0, 0, (domain, 5222))]
async def find_xmpp_host_tlsa(loop, domain, attempts=3, require_ad=True):
domain = domain.encode("IDNA")
items = await lookup_tlsa(
domain=domain,
port=5222,
nattempts=attempts,
require_ad=require_ad
)
if items is not None:
return items
return []
aioxmpp/node.py 0000664 0000000 0000000 00000164264 14160146213 0014045 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: node.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.node` --- XMPP network nodes (clients, mostly)
#############################################################
This module contains functions to connect to an XMPP server, as well as
maintaining the stream. In addition, a client class which completely manages a
stream based on a presence setting is provided.
Using XMPP
==========
.. currentmodule:: aioxmpp
.. autoclass:: Client
.. autoclass:: PresenceManagedClient
.. currentmodule:: aioxmpp.node
.. class:: AbstractClient
Alias of :class:`Client`.
.. deprecated:: 0.8
The alias will be removed in 1.0.
Connecting streams low-level
============================
.. autofunction:: discover_connectors
.. autofunction:: connect_xmlstream
Utilities
=========
.. autoclass:: UseConnected
"""
import asyncio
import contextlib
import logging
import warnings
from datetime import timedelta
import dns.resolver
import OpenSSL.SSL
import aiosasl
from . import (
connector,
network,
protocol,
errors,
stream,
callbacks,
nonza,
rfc3921,
rfc6120,
stanza,
structs,
security_layer,
dispatcher,
presence as mod_presence,
)
logger = logging.getLogger(__name__)
async def lookup_addresses(loop, jid):
addresses = await network.find_xmpp_host_addr(
loop,
jid.domain)
return network.group_and_order_srv_records(addresses)
async def discover_connectors(domain: str, loop=None, logger=logger):
"""
Discover all connection options for a domain, in descending order of
preference.
This coroutine returns options discovered from SRV records, or if none are
found, the generic option using the domain name and the default XMPP client
port.
Each option is represented by a triple ``(host, port, connector)``.
`connector` is a :class:`aioxmpp.connector.BaseConnector` instance which is
suitable to connect to the given host and port.
`logger` is the logger used by the function.
The following sources are supported:
* :rfc:`6120` SRV records. One option is returned per SRV record.
If one of the SRV records points to the root name (``.``),
:class:`ValueError` is raised (the domain specifically said that XMPP is
not supported here).
* :xep:`368` SRV records. One option is returned per SRV record.
* :rfc:`6120` fallback process (only if no SRV records are found). One
option is returned for the host name with the default XMPP client port.
The options discovered from SRV records are mixed together, ordered by
priority and then within priorities are shuffled according to their weight.
Thus, if there are multiple records of equal priority, the result of the
function is not deterministic.
.. versionadded:: 0.6
"""
domain_encoded = domain.encode("idna") + b"."
starttls_srv_failed = False
tls_srv_failed = False
try:
starttls_srv_records = await network.lookup_srv(
domain_encoded,
"xmpp-client",
)
starttls_srv_disabled = False
except dns.resolver.NoNameservers as exc:
starttls_srv_records = []
starttls_srv_disabled = False
starttls_srv_failed = True
starttls_srv_exc = exc
logger.debug("xmpp-client SRV lookup for domain %s failed "
"(may not be fatal)",
domain_encoded,
exc_info=True)
except ValueError:
starttls_srv_records = []
starttls_srv_disabled = True
try:
tls_srv_records = await network.lookup_srv(
domain_encoded,
"xmpps-client",
)
tls_srv_disabled = False
except dns.resolver.NoNameservers:
tls_srv_records = []
tls_srv_disabled = False
tls_srv_failed = True
logger.debug("xmpps-client SRV lookup for domain %s failed "
"(may not be fatal)",
domain_encoded,
exc_info=True)
except ValueError:
tls_srv_records = []
tls_srv_disabled = True
if starttls_srv_failed and (tls_srv_failed or tls_srv_records is None):
# the failure is probably more useful as a diagnostic
# if we find a good reason to allow this scenario, we might change it
# later.
raise starttls_srv_exc
if starttls_srv_disabled and (tls_srv_disabled or tls_srv_records is None):
raise ValueError(
"XMPP not enabled on domain {!r}".format(domain),
)
if starttls_srv_records is None and tls_srv_records is None:
# no SRV records published, fall back
logger.debug(
"no SRV records found for %s, falling back",
domain,
)
return [
(domain, 5222, connector.STARTTLSConnector()),
]
starttls_srv_records = starttls_srv_records or []
tls_srv_records = tls_srv_records or []
srv_records = [
(prio, weight, (host.decode("ascii"), port,
connector.STARTTLSConnector()))
for prio, weight, (host, port) in starttls_srv_records
]
srv_records.extend(
(prio, weight, (host.decode("ascii"), port,
connector.XMPPOverTLSConnector()))
for prio, weight, (host, port) in tls_srv_records
)
options = list(
network.group_and_order_srv_records(srv_records)
)
logger.debug(
"options for %s: %r",
domain,
options,
)
return options
async def _try_options(options, exceptions,
jid, metadata, negotiation_timeout, loop, logger):
"""
Helper function for :func:`connect_xmlstream`.
"""
for host, port, conn in options:
logger.debug(
"domain %s: trying to connect to %r:%s using %r",
jid.domain, host, port, conn
)
try:
transport, xmlstream, features = await conn.connect(
loop,
metadata,
jid.domain,
host,
port,
negotiation_timeout,
base_logger=logger,
)
except OSError as exc:
logger.warning(
"connection failed: %s", exc
)
exceptions.append(exc)
continue
logger.debug(
"domain %s: connection succeeded using %r",
jid.domain,
conn,
)
if not metadata.sasl_providers:
return transport, xmlstream, features
try:
features = await security_layer.negotiate_sasl(
transport,
xmlstream,
metadata.sasl_providers,
negotiation_timeout=None,
jid=jid,
features=features,
)
except errors.SASLUnavailable as exc:
protocol.send_stream_error_and_close(
xmlstream,
condition=errors.StreamErrorCondition.POLICY_VIOLATION,
text=str(exc),
)
exceptions.append(exc)
continue
except Exception as exc:
protocol.send_stream_error_and_close(
xmlstream,
condition=errors.StreamErrorCondition.UNDEFINED_CONDITION,
text=str(exc),
)
raise
return transport, xmlstream, features
return None
async def connect_xmlstream(
jid,
metadata,
negotiation_timeout=60.,
override_peer=[],
loop=None,
logger=logger):
"""
Prepare and connect a :class:`aioxmpp.protocol.XMLStream` to a server
responsible for the given `jid` and authenticate against that server using
the SASL mechanisms described in `metadata`.
:param jid: Address of the user for which the connection is made.
:type jid: :class:`aioxmpp.JID`
:param metadata: Connection metadata for configuring the TLS usage.
:type metadata: :class:`~.security_layer.SecurityLayer`
:param negotiation_timeout: Timeout for each individual negotiation step.
:type negotiation_timeout: :class:`float` in seconds
:param override_peer: Sequence of connection options which take precedence
over normal discovery methods.
:type override_peer: sequence of (:class:`str`, :class:`int`,
:class:`~.BaseConnector`) triples
:param loop: asyncio event loop to use (defaults to current)
:type loop: :class:`asyncio.BaseEventLoop`
:param logger: Logger to use (defaults to module-wide logger)
:type logger: :class:`logging.Logger`
:raises ValueError: if the domain from the `jid` announces that XMPP is not
supported at all.
:raises aioxmpp.errors.TLSFailure: if all connection attempts fail and one
of them is a :class:`~.TLSFailure`.
:raises aioxmpp.errors.MultiOSError: if all connection attempts fail.
:return: Transport, XML stream and the current stream features
:rtype: tuple of (:class:`asyncio.BaseTransport`, :class:`~.XMLStream`,
:class:`~.nonza.StreamFeatures`)
The part of the `metadata` specifying the use of TLS is applied. If the
security layer does not mandate TLS, the resulting XML stream may not be
using TLS. TLS is used whenever possible.
The connection options in `override_peer` are tried before any standardised
discovery of connection options is made. Only if all of them fail,
automatic discovery of connection options is performed.
`loop` may be a :class:`asyncio.BaseEventLoop` to use. Defaults to the
current event loop.
If the domain from the `jid` announces that XMPP is not supported at all,
:class:`ValueError` is raised. If no options are returned from
:func:`discover_connectors` and `override_peer` is empty,
:class:`ValueError` is raised, too.
If all connection attempts fail, :class:`aioxmpp.errors.MultiOSError` is
raised. The error contains one exception for each of the options discovered
as well as the elements from `override_peer` in the order they were tried.
A TLS problem is treated like any other connection problem and the other
connection options are considered. However, if *all* connection options
fail and the set of encountered errors includes a TLS error, the TLS error
is re-raised instead of raising a :class:`aioxmpp.errors.MultiOSError`.
Return a triple ``(transport, xmlstream, features)``. `transport`
the underlying :class:`asyncio.Transport` which is used for the `xmlstream`
:class:`~.protocol.XMLStream` instance. `features` is the
:class:`aioxmpp.nonza.StreamFeatures` instance describing the features of
the stream.
.. versionadded:: 0.6
.. versionchanged:: 0.8
The explicit raising of TLS errors has been introduced. Before, TLS
errors were treated like any other connection error, possibly masking
configuration problems.
"""
loop = asyncio.get_event_loop() if loop is None else loop
options = list(override_peer)
exceptions = []
result = await _try_options(
options,
exceptions,
jid, metadata, negotiation_timeout, loop, logger,
)
if result is not None:
return result
options = list(await discover_connectors(
jid.domain,
loop=loop,
logger=logger,
))
result = await _try_options(
options,
exceptions,
jid, metadata, negotiation_timeout, loop, logger,
)
if result is not None:
return result
if not options and not override_peer:
raise ValueError("no options to connect to XMPP domain {!r}".format(
jid.domain
))
for exc in exceptions:
if isinstance(exc, errors.TLSFailure):
raise exc
raise errors.MultiOSError(
"failed to connect to XMPP domain {!r}".format(jid.domain),
exceptions
)
class Client:
"""
Base class to implement an XMPP client.
Args:
local_jid (:class:`~aioxmpp.JID`): Jabber ID to connect as.
security_layer (:class:`~aioxmpp.SecurityLayer`): Configuration
for authentication and TLS.
negotiation_timeout (:class:`datetime.timedelta`): Timeout for the
individual stream negotiation steps (bounds initial connect time)
override_peer: Connection options which take precedence over the
standardised connection options
max_inital_attempts (:class:`int`): Maximum number of initial
connection attempts before giving up.
loop (:class:`asyncio.BaseEventLoop` or :data:`None`): Override the
:mod:`asyncio` event loop to use.
logger (:class:`logging.Logger` or :data:`None`): Override the logger
to use.
These classes deal with managing the :class:`~aioxmpp.stream.StanzaStream`
and the underlying :class:`~aioxmpp.protocol.XMLStream` instances. The
abstract client provides functionality for connecting the xmlstream as well
as signals which indicate changes in the stream state.
The `security_layer` is best created using the
:func:`aioxmpp.security_layer.make` function and must provide
authentication for the given `local_jid`.
If `loop` is given, it must be a :class:`asyncio.BaseEventLoop`
instance. If it is not given, the current event loop is used.
As a glue between the stanza stream and the XML stream, it also knows about
stream management and performs stream management negotiation. It is
specialized on client operations, which implies that it will try to keep
the stream alive as long as wished by the client.
The client will attempt to connect to the server(s) associated with the
`local_jid`, using the prioritised `override_peer` setting or the
standardised options for connecting (see :meth:`discover_connectors`). The
initial connection attempt must succeed within `max_initial_attempts`.
If the connection breaks after the first connection attempt, the client
will try to resume the connection transparently. If the server supports
stream management (:xep:`198`) with resumption, this is entirely
transparent to all operations over the stream. If the stream is not
resumable or the resumption fails and `allow_implicit_reconnect` is true,
the application and services using the stream are notified about that. If,
in that situation, `allow_implicit_reconnect` is false instead, the client
stops with an error.
The number of reconnection attempts is generally unbounded. The application
is notified that the stream got interrupted with the
:meth:`on_stream_suspended` is emitted. After reconnection,
:meth:`on_stream_established` is emitted (possibly preceded by a
:meth:`on_stream_destroyed` emission if the stream failed to resume). If
the application wishes to bound the time the stream tries to transparently
reconnect, it should connect to the :meth:`on_stream_suspended` signal and
stop the stream as needed.
The reconnection attempts are throttled using expenential backoff
controlled by the :attr:`backoff_start`, :attr:`backoff_factor` and
:attr:`backoff_cap` attributes.
.. note::
If `max_initial_attempts` is :data:`None`, the stream will try
indefinitely to connect to the server even if the connection has
never succeeded yet. This is may mask problems with the configuration of
the client itself, because the client cannot successfully distinguish
permanent problems arising from the configuration (of the client or the
server) from problems arising from transient problems such as network
failures.
This may severely degrade usabilty, because the client is then stuck in
a connect loop without any usable feedback. Setting a bound for the
initial connection attempt is usually better, for interactive
applications an upper bound of 1 might make most sense (possibly the
interactive application may retry on its own if the user did not
indicate that they wish to do so after a timeout). We’ll leave the UX
considerations up to you.
.. versionchanged:: 0.4
Since 0.4, support for legacy XMPP sessions has been implemented. Mainly
for compatibility with ejabberd.
.. versionchanged:: 0.8
The amount of initial connection attempts is now bounded by
`max_initial_attempts`. The :meth:`on_stream_suspended` signal and the
associated logic has been introduced.
Controlling the client:
.. automethod:: connected
.. automethod:: start
.. automethod:: stop
.. autoattribute:: running
.. attribute:: negotiation_timeout
:annotation: = timedelta(seconds=60)
The timeout applied to the connection process and the individual steps
of negotiating the stream. See the `negotiation_timeout` argument to
:func:`connect_xmlstream`.
.. attribute:: override_peer
A sequence of triples ``(host, port, connector)``, where `host` must be
a host name or IP as string, `port` must be a port number and
`connector` must be a :class:`aioxmpp.connector.BaseConnctor` instance.
These connection options are passed to :meth:`connect_xmlstream` and
thus take precedence over the options discovered using
:meth:`discover_connectors`.
.. note::
If Stream Management is used and the peer server provided a location
to connect to on resumption, that location is preferred even over the
options set here.
.. versionadded:: 0.6
.. autoattribute:: resumption_timeout
:annotation: = None
Connection information:
.. autoattribute:: established
.. attribute:: established_event
An :class:`asyncio.Event` which indicates that the stream is
established. A stream is valid after resource binding and before it has
been destroyed.
While this event is cleared, :meth:`enqueue` fails with
:class:`ConnectionError` and :meth:`send` blocks.
.. autoattribute:: suspended
.. autoattribute:: local_jid
.. attribute:: stream
The :class:`~aioxmpp.stream.StanzaStream` instance used by the node.
.. attribute:: stream_features
An instance of :class:`~aioxmpp.nonza.StreamFeatures`. This is the
most-recently received stream features information (the one received
right before resource binding).
While no stream has been established yet, this is :data:`None`. During
transparent re-negotiation, that information may be obsolete. However,
when :attr:`before_stream_established` fires, the information is
up-to-date.
Sending stanzas:
.. automethod:: send
.. automethod:: enqueue
Configuration of exponential backoff for reconnects:
.. attribute:: backoff_start
:annotation: = timedelta(1)
When an underlying XML stream fails due to connectivity issues (generic
:class:`OSError` raised), exponential backoff takes place before
attempting to reconnect.
The initial time to wait before reconnecting is described by
:attr:`backoff_start`.
.. attribute:: backoff_factor
:annotation: = 1.2
Each subsequent time a connection fails, the previous backoff time is
multiplied with :attr:`backoff_factor`.
.. attribute:: backoff_cap
:annotation: = timedelta(60)
The backoff time is capped to :attr:`backoff_cap`, to avoid having
unrealistically high values.
Signals:
.. signal:: on_failure(err)
This signal is fired when the client fails and stops.
.. syncsignal:: before_stream_established()
This coroutine signal is executed right before
:meth:`on_stream_established` fires.
.. signal:: on_stopped()
Fires when the client stops gracefully. This is the counterpart to
:meth:`on_failure`.
.. signal:: on_stream_established()
When the stream is established and resource binding took place, this
event is fired. It means that the stream can now be used for XMPP
interactions.
.. signal:: on_stream_suspended(reason)
The stream has been suspened due to a connection failure.
:param reason: The exception which terminated the stream.
:type reason: :class:`Exception`
This signal may be immediately followed by a
:meth:`on_stream_destroyed`, if the stream did not support stream
resumption. Otherwise, a new connection is attempted transparently.
In general, this signal exists solely for informational purposes. It
can be used to drive a user interface which indicates that messages may
be delivered with delay, because the underlying network is transiently
interrupted.
:meth:`on_stream_suspended` is not emitted if the stream was stopped on
user request.
After :meth:`on_stream_suspended` is emitted, one of the two following
signals is emitted:
- :meth:`on_stream_destroyed` indicates that state was actually lost and
that others most likely see or saw an unavailable presence broadcast
for the resource.
- :meth:`on_stream_resumed` indicates that no state was lost and the
stream is fully usable again.
.. versionadded:: 0.8
.. signal:: on_stream_resumed()
The stream has been resumed after it has been suspended, without loss
of data.
This is the counterpart to :meth:`on_stream_suspended`.
In general, this signal exists solely for informational purposes. It
can be used to drive a user interface which indicates that messages may
be delivered with delay, because the underlying network is transiently
interrupted.
.. versionadded:: 0.11
.. signal:: on_stream_destroyed(reason=None)
This is called whenever a stream is destroyed. The conditions for this
are the same as for
:attr:`aioxmpp.stream.StanzaStream.on_stream_destroyed`.
:param reason: An optional exception which indicates the reason for the
destruction of the stream.
:type reason: :class:`Exception`
This event can be used to know when to discard all state about the XMPP
connection, such as roster information. Services implemented in
:mod:`aioxmpp` generally subscribe to this signal to discard cached
state.
`reason` is optional. It is given if there is has been a specific
exception which describes the cause for the stream destruction, such as
a :class:`ConnectionError`.
.. versionchanged:: 0.8
The `reason` argument was added.
Services:
.. automethod:: summon
Miscellaneous:
.. attribute:: logger
The :class:`logging.Logger` instance which is used by the
:class:`Client`. This is the `logger` passed to the constructor or a
logger derived from the fully qualified name of the class.
.. versionadded:: 0.6
The :attr:`logger` attribute was added.
"""
on_failure = callbacks.Signal()
on_stopped = callbacks.Signal()
on_stream_destroyed = callbacks.Signal()
on_stream_suspended = callbacks.Signal()
on_stream_resumed = callbacks.Signal()
on_stream_established = callbacks.Signal()
before_stream_established = callbacks.SyncSignal()
def __init__(self,
local_jid,
security_layer,
*,
negotiation_timeout=timedelta(seconds=60),
max_initial_attempts=4,
override_peer=[],
loop=None,
logger=None):
super().__init__()
self._local_jid = local_jid
self._loop = loop or asyncio.get_event_loop()
self._main_task = None
self._security_layer = security_layer
self._failure_future = asyncio.Future()
self.logger = (logger or
logging.getLogger(".".join([
type(self).__module__,
type(self).__qualname__,
])))
self._backoff_time = None
self._is_suspended = False
# track whether the connection succeeded *at least once*
# used to enforce max_initial_attempts
self._had_connection = False
self._nattempt = 0
self._services = {}
self.stream_features = None
self.negotiation_timeout = negotiation_timeout
self.backoff_start = timedelta(seconds=1)
self.backoff_factor = 1.2
self.backoff_cap = timedelta(seconds=60)
self.override_peer = list(override_peer)
self.established_event = asyncio.Event()
self._max_initial_attempts = max_initial_attempts
self._resumption_timeout = None
self.on_stopped.logger = self.logger.getChild("on_stopped")
self.on_failure.logger = self.logger.getChild("on_failure")
self.on_stream_established.logger = \
self.logger.getChild("on_stream_established")
self.on_stream_destroyed.logger = \
self.logger.getChild("on_stream_destroyed")
self.on_stream_suspended.logger = \
self.logger.getChild("on_stream_suspended")
if logger is not None:
stream_base_logger = self.logger
else:
stream_base_logger = logging.getLogger("aioxmpp")
self.stream = stream.StanzaStream(
local_jid.bare(),
base_logger=stream_base_logger
)
self.stream._xxx_message_dispatcher = self.summon(
dispatcher.SimpleMessageDispatcher,
)
self.stream._xxx_presence_dispatcher = self.summon(
dispatcher.SimplePresenceDispatcher,
)
def send_warner(*args, **kwargs):
warnings.warn("send() on StanzaStream is deprecated and will "
"be removed in 1.0. Use send() on the Client "
"instead.",
DeprecationWarning,
stacklevel=1)
return self.send(*args, **kwargs)
self.stream.send = send_warner
def enqueue_warner(*args, **kwargs):
warnings.warn("enqueue() on StanzaStream is deprecated and will "
"be removed in 1.0. Use enqueue() on the Client "
"instead.",
DeprecationWarning,
stacklevel=1)
return self.enqueue(*args, **kwargs)
self.stream.enqueue = enqueue_warner
def _stream_failure(self, exc):
if self._failure_future.done():
self.logger.warning(
"something is odd: failure future is already done ..."
)
return
if not self._is_suspended:
self.on_stream_suspended(exc)
self._is_suspended = True
self._failure_future.set_result(exc)
self._failure_future = asyncio.Future()
def _stream_destroyed(self, reason):
if not self._is_suspended:
if not isinstance(reason, stream.DestructionRequested):
self.on_stream_suspended(reason)
self._is_suspended = True
if self.established_event.is_set():
self.established_event.clear()
self.on_stream_destroyed()
def _on_main_done(self, task):
try:
task.result()
except asyncio.CancelledError:
# task terminated normally
self.on_stopped()
except Exception as err:
self.logger.exception("main failed")
self.on_failure(err)
async def _try_resume_stream_management(self, xmlstream, features):
try:
await self.stream.resume_sm(xmlstream)
except errors.StreamNegotiationFailure as exc:
self.logger.warning("failed to resume stream (%s)", exc)
return False
return True
async def _negotiate_legacy_session(self):
self.logger.debug(
"remote server announces support for legacy sessions"
)
await self.stream._send_immediately(
stanza.IQ(type_=structs.IQType.SET,
payload=rfc3921.Session())
)
self.logger.debug(
"legacy session negotiated (upgrade your server!)"
)
async def _negotiate_stream(self, xmlstream, features):
server_can_do_sm = True
try:
features[nonza.StreamManagementFeature]
except KeyError:
if self.stream.sm_enabled:
self.logger.warning("server isn’t advertising SM anymore")
self.stream.stop_sm()
server_can_do_sm = False
self.logger.debug("negotiating stream (server_can_do_sm=%s)",
server_can_do_sm)
if self.stream.sm_enabled:
resumed = await self._try_resume_stream_management(
xmlstream, features)
if resumed:
return features, resumed
else:
resumed = False
self.stream_features = features
self.stream.start(xmlstream)
if not resumed:
self.logger.debug("binding to resource")
await self._bind()
if server_can_do_sm:
self.logger.debug("attempting to start stream management")
try:
await self.stream.start_sm(
resumption_timeout=self._resumption_timeout
)
except errors.StreamNegotiationFailure:
self.logger.debug("stream management failed to start")
self.logger.debug("stream management started")
try:
session_feature = features[rfc3921.SessionFeature]
except KeyError:
pass # yay
else:
if not session_feature.optional:
await self._negotiate_legacy_session()
else:
self.logger.debug(
"skipping optional legacy session negotiation"
)
self.established_event.set()
await self.before_stream_established()
self.on_stream_established()
return features, resumed
async def _bind(self):
iq = stanza.IQ(type_=structs.IQType.SET)
iq.payload = rfc6120.Bind(resource=self._local_jid.resource)
try:
result = await self.stream._send_immediately(iq)
except errors.XMPPError as exc:
raise errors.StreamNegotiationFailure(
"Resource binding failed: {}".format(exc)
)
self._local_jid = result.jid
self.stream.local_jid = result.jid.bare()
self.logger.info("bound to jid: %s", self._local_jid)
async def _main_impl(self):
failure_future = self._failure_future
override_peer = []
if self.stream.sm_enabled:
sm_location = self.stream.sm_location
if sm_location:
override_peer.append((
str(sm_location[0]),
sm_location[1],
connector.STARTTLSConnector(),
))
override_peer += self.override_peer
tls_transport, xmlstream, features = await connect_xmlstream(
self._local_jid,
self._security_layer,
negotiation_timeout=self.negotiation_timeout.total_seconds(),
override_peer=override_peer,
loop=self._loop,
logger=self.logger)
self._had_connection = True
try:
features, sm_resumed = await self._negotiate_stream(
xmlstream,
features)
if self._is_suspended:
self.on_stream_resumed()
self._is_suspended = False
self._backoff_time = None
exc = await failure_future
self.logger.error("stream failed: %s", exc)
raise exc
except asyncio.CancelledError:
self.logger.info("client shutting down (on request)")
# cancelled, this means a clean shutdown is requested
await self.stream.close()
raise
finally:
self.logger.info("stopping stream")
self.stream.stop()
async def _main(self):
with contextlib.ExitStack() as stack:
stack.enter_context(
self.stream.on_failure.context_connect(self._stream_failure)
)
stack.enter_context(
self.stream.on_stream_destroyed.context_connect(
self._stream_destroyed)
)
while True:
self._nattempt += 1
self._failure_future = asyncio.Future()
try:
await self._main_impl()
except errors.StreamError as err:
if err.condition == errors.StreamErrorCondition.CONFLICT:
self.logger.debug("conflict!")
raise
except (errors.StreamNegotiationFailure,
aiosasl.SASLError):
if self.stream.sm_enabled:
self.stream.stop_sm()
raise
except (OSError, dns.resolver.NoNameservers,
OpenSSL.SSL.Error) as exc:
self.logger.info("connection error: (%s) %s",
type(exc).__qualname__,
exc)
if (not self._had_connection and
self._max_initial_attempts is not None and
self._nattempt >= self._max_initial_attempts):
self.logger.warning("out of connection attempts")
raise
if self._backoff_time is None:
self._backoff_time = self.backoff_start.total_seconds()
self.logger.debug("re-trying after %.1f seconds",
self._backoff_time)
await asyncio.sleep(self._backoff_time)
self._backoff_time *= self.backoff_factor
if self._backoff_time > self.backoff_cap.total_seconds():
self._backoff_time = self.backoff_cap.total_seconds()
continue # retry
def start(self):
"""
Start the client. If it is already :attr:`running`,
:class:`RuntimeError` is raised.
While the client is running, it will try to keep an XMPP connection
open to the server associated with :attr:`local_jid`.
"""
if self.running:
raise RuntimeError("client already running")
self._main_task = asyncio.ensure_future(
self._main(),
loop=self._loop
)
self._main_task.add_done_callback(self._on_main_done)
def stop(self):
"""
Stop the client. This sends a signal to the clients main task which
makes it terminate.
It may take some cycles through the event loop to stop the client
task. To check whether the task has actually stopped, query
:attr:`running`.
"""
if not self.running:
return
self.logger.debug("stopping main task of %r", self, stack_info=True)
self._main_task.cancel()
def _summon(self, class_, visited):
# this is essentially a topological sort algorithm
try:
return self._services[class_]
except KeyError:
if class_ in visited:
raise ValueError("dependency loop")
visited.add(class_)
# summon dependencies before taking len(self._services) as
# the instantiation index of the service
dependencies = {
depclass: self._summon(depclass, visited)
for depclass in class_.PATCHED_ORDER_AFTER
}
service_order_index = len(self._services)
instance = class_(
self,
logger_base=self.logger,
dependencies=dependencies,
service_order_index=service_order_index,
)
self._services[class_] = instance
return instance
def summon(self, class_):
"""
Summon a :class:`~aioxmpp.service.Service` for the client.
If the `class_` has already been summoned for the client, it’s instance
is returned.
Otherwise, all requirements for the class are first summoned (if they
are not there already). Afterwards, the class itself is summoned and
the instance is returned.
"""
return self._summon(class_, set())
# properties
@property
def local_jid(self):
"""
The :class:`~aioxmpp.JID` the client currently has. While the
client is disconnected, which parts of the :attr:`local_jid` can be
relied upon depends on the authentication mechanism used. For example,
using anonymous authentication, the server dictates even the local part
of the JID and it will change after a reconnect. For more common
authentication schemes (such as normal password-based authentication),
the localpart is usually chosen by the client.
For interoperability with different authentication schemes, code must
invalidate all copies of this attribute when a
:meth:`on_stream_established` or :meth:`on_stream_destroyed` event is
emitted.
Writing this attribute is not allowed, as changing the JID introduces a
lot of issues with respect to reusability of the stream. Instantiate a
new :class:`Client` if you need to change the bare part of the JID.
.. note::
Changing the resource between reconnects may be allowed later.
"""
return self._local_jid
@property
def running(self):
"""
true if the client is currently running, false otherwise.
"""
return self._main_task is not None and not self._main_task.done()
@property
def established(self):
"""
true if the stream is currently established (as defined in
:attr:`on_stream_established`) and false otherwise.
"""
return self.established_event.is_set()
@property
def suspended(self):
"""
true if the stream is currently suspended (see
:meth:`on_stream_suspended`)
.. versionadded:: 0.11
"""
return self._is_suspended
@property
def resumption_timeout(self):
"""
The maximum time as integer in seconds for which the server shall hold
on to the session if the underlying transport breaks.
This is only relevant if the server supports
:xep:`Stream Management <198>` and the server may ignore the request
for a maximum timeout and/or impose its own maximum. After the
stream has been negotiated, :attr:`.StanzaStream.sm_max` holds the
actual timeout announced by the server (may be :data:`None` if the
server did not specify a timeout).
The default value of :data:`None` does not request any specific
timeout from the server and leaves it up to the server to decide.
Setting a :attr:`resumption_timeout` of zero (0) disables resumption.
.. versionadded:: 0.9
"""
return self._resumption_timeout
@resumption_timeout.setter
def resumption_timeout(self, value):
if (value is not None and
(not isinstance(value, int) or isinstance(value, bool))):
raise TypeError(
"resumption_timeout must be int or None, got {!r}".format(
value
)
)
if value is not None and value < 0:
raise ValueError(
"resumption timeout must be non-negative or None"
)
self._resumption_timeout = value
def connected(self, *, presence=structs.PresenceState(False), **kwargs):
"""
Return a :class:`.node.UseConnected` context manager which does not
modify the presence settings.
The keyword arguments are passed to the :class:`.node.UseConnected`
context manager constructor.
.. versionadded:: 0.8
"""
return UseConnected(self, presence=presence, **kwargs)
def enqueue(self, stanza, **kwargs):
"""
Put a `stanza` in the internal transmission queue and return a token to
track it.
:param stanza: Stanza to send
:type stanza: :class:`IQ`, :class:`Message` or :class:`Presence`
:param kwargs: see :class:`StanzaToken`
:raises ConnectionError: if the stream is not :attr:`established`
yet.
:return: token which tracks the stanza
:rtype: :class:`StanzaToken`
The `stanza` is enqueued in the active queue for transmission and will
be sent on the next opportunity. The relative ordering of stanzas
enqueued is always preserved.
Return a fresh :class:`StanzaToken` instance which traks the progress
of the transmission of the `stanza`. The `kwargs` are forwarded to the
:class:`StanzaToken` constructor.
This method calls :meth:`~.stanza.StanzaBase.autoset_id` on the stanza
automatically.
.. seealso::
:meth:`send`
for a more high-level way to send stanzas.
.. versionchanged:: 0.10
This method has been moved from
:meth:`aioxmpp.stream.StanzaStream.enqueue`.
"""
if not self.established_event.is_set():
raise ConnectionError("stream is not ready")
return self.stream._enqueue(stanza, **kwargs)
async def send(self, stanza, *, timeout=None, cb=None):
"""
Send a stanza.
:param stanza: Stanza to send
:type stanza: :class:`~.IQ`, :class:`~.Presence` or :class:`~.Message`
:param timeout: Maximum time in seconds to wait for an IQ response, or
:data:`None` to disable the timeout.
:type timeout: :class:`~numbers.Real` or :data:`None`
:param cb: Optional callback which is called synchronously when the
reply is received (IQ requests only!)
:raise OSError: if the underlying XML stream fails and stream
management is not disabled.
:raise aioxmpp.stream.DestructionRequested:
if the stream is closed while sending the stanza or waiting for a
response.
:raise aioxmpp.errors.XMPPError: if an error IQ response is received
:raise aioxmpp.errors.ErroneousStanza: if the IQ response could not be
parsed
:raise ValueError: if `cb` is given and `stanza` is not an IQ request.
:return: IQ response :attr:`~.IQ.payload` or :data:`None`
Send the stanza and wait for it to be sent. If the stanza is an IQ
request, the response is awaited and the :attr:`~.IQ.payload` of the
response is returned.
If the stream is currently not ready, this method blocks until the
stream is ready to send payload stanzas. Note that this may be before
initial presence has been sent. To synchronise with that type of
events, use the appropriate signals.
The `timeout` as well as any of the exception cases referring to a
"response" do not apply for IQ response stanzas, message stanzas or
presence stanzas sent with this method, as this method only waits for
a reply if an IQ *request* stanza is being sent.
If `stanza` is an IQ request and the response is not received within
`timeout` seconds, :class:`TimeoutError` (not
:class:`asyncio.TimeoutError`!) is raised.
If `cb` is given, `stanza` must be an IQ request (otherwise,
:class:`ValueError` is raised before the stanza is sent). It must be a
callable returning an awaitable. It receives the response stanza as
first and only argument. The returned awaitable is awaited by
:meth:`send` and the result is returned instead of the original
payload. `cb` is called synchronously from the stream handling loop
when the response is received, so it can benefit from the strong
ordering guarantees given by XMPP XML Streams.
The `cb` may also return :data:`None`, in which case :meth:`send` will
simply return the IQ payload as if `cb` was not given. Since the return
value of coroutine functions is awaitable, it is valid and supported to
pass a coroutine function as `cb`.
.. warning::
Remember that it is an implementation detail of the event loop when
a coroutine is scheduled after it awaited an awaitable; this
implies that if the caller of :meth:`send` is merely awaiting the
:meth:`send` coroutine, the strong ordering guarantees of XMPP XML
Streams are lost.
To regain those, use the `cb` argument.
.. note::
For the sake of readability, unless you really need the strong
ordering guarantees, avoid the use of the `cb` argument. Avoid
using a coroutine function unless you really need to.
.. versionchanged:: 0.10
* This method now waits until the stream is ready to send stanza¸
payloads.
* This method was moved from
:meth:`aioxmpp.stream.StanzaStream.send`.
.. versionchanged:: 0.9
The `cb` argument was added.
.. versionadded:: 0.8
"""
if not self.running:
raise ConnectionError("client is not running")
if not self.established:
self.logger.debug("send(%s): stream not established, waiting",
stanza)
# wait for the stream to be established
stopped_fut = self.on_stopped.future()
failure_fut = self.on_failure.future()
established_fut = asyncio.ensure_future(
self.established_event.wait()
)
done, pending = await asyncio.wait(
[
established_fut,
failure_fut,
stopped_fut,
],
return_when=asyncio.FIRST_COMPLETED,
)
if not established_fut.done():
established_fut.cancel()
if failure_fut.done():
if not stopped_fut.done():
stopped_fut.cancel()
failure_fut.exception()
raise ConnectionError("client failed to connect")
if stopped_fut.done():
raise ConnectionError("client shut down by user request")
self.logger.debug("send(%s): stream established, sending")
return await self.stream._send_immediately(stanza,
timeout=timeout,
cb=cb)
class PresenceManagedClient(Client):
"""
Client whose connection is controlled by its configured presence.
.. seealso::
:class:`Client`
for a description of the arguments.
The presence is set using :attr:`presence` or the :class:`PresenceServer`
service. If the set presence is an *available* presence, the client is
started (if it is not already running). If the set presence is an
*unavailable* presence, the unavailable presence is broadcast and the
client is stopped.
While the start/stop interfaces of :class:`~.Client` are still available,
using them may interfere with the behaviour of the presence automagic.
The initial presence is set to `unavailable`, thus, the client will not
connect immediately.
.. autoattribute:: presence
.. automethod:: set_presence
.. automethod:: connected
Signals:
.. attribute:: on_presence_sent
The event is fired after :meth:`.Client.on_stream_established` and after
the current presence has been sent to the server as *initial presence*.
.. versionchanged:: 0.8
Since 0.8, the :class:`PresenceManagedClient` is implemented on top of
:class:`PresenceServer`. Changing the presence via the
:class:`PresenceServer` has the same effect as writing :attr:`presence`
or calling :meth:`set_presence`.
"""
on_presence_sent = callbacks.Signal()
def __init__(self, jid, security_layer, **kwargs):
super().__init__(jid, security_layer, **kwargs)
self._presence_server = self.summon(mod_presence.PresenceServer)
self._presence_server.on_presence_state_changed.connect(
self._update_presence
)
self.on_stream_established.connect(self._handle_stream_established)
def _handle_stream_established(self):
self.on_presence_sent()
def _update_presence(self):
if self._presence_server.state.available:
if not self.running:
self.start()
else:
if self.running:
self.stop()
@property
def presence(self):
"""
Control or query the current presence state (see
:class:`~.PresenceState`) of the client. Note that when
reading, the property only returns the "set" value, not the actual
value known to the server (and others). This may differ if the
connection is still being established.
.. seealso::
Setting the presence state using :attr:`presence` clears the
`status` of the presence. To set the status and state at once,
use :meth:`set_presence`.
Upon setting this attribute, the :class:`PresenceManagedClient` will do
whatever necessary to achieve the given presence. If the presence is
an `available` presence, the client will attempt to connect to the
server. If the presence is `unavailable` and the client is currently
connected, it will disconnect.
Instead of setting the presence to unavailable, :meth:`stop` can also
be called. The :attr:`presence` attribute is *not* affected by calls to
:meth:`start` or :meth:`stop`.
"""
return self._presence_server.state
@presence.setter
def presence(self, value):
call_update = value == self.presence
self._presence_server.set_presence(value)
if call_update:
self._update_presence()
def set_presence(self, state, status):
"""
Set the presence `state` and `status` on the client. This has the same
effects as writing `state` to :attr:`presence`, but the status of the
presence is also set at the same time.
`status` must be either a string or something which can be passed to
:class:`dict`. If it is a string, the string is wrapped in a ``{None:
status}`` dictionary. Otherwise, the dictionary is set as the
:attr:`~.Presence.status` attribute of the presence stanza. It
must map :class:`aioxmpp.structs.LanguageTag` instances to strings.
The `status` is the text shown alongside the `state` (indicating
availability such as *away*, *do not disturb* and *free to chat*).
"""
self._presence_server.set_presence(state, status=status)
def connected(self, **kwargs):
"""
Return a :class:`.node.UseConnected` context manager which sets the
presence to available.
The keyword arguments are passed to the :class:`.node.UseConnected`
context manager constructor.
.. note::
In contrast to the same method on :class:`Client`, this method
implies setting an available presence.
.. versionadded:: 0.6
"""
return UseConnected(self, **kwargs)
class UseConnected:
"""
Asynchronous context manager which connects and disconnects a
:class:`.Client`.
:param client: The client to manage
:type client: :class:`.Client`
:param timeout: Limit on the time it may take to start the client
:type timeout: :class:`datetime.timedelta` or :data:`None`
:param presence: Presence state to set on the client (deprecated)
:type presence: :class:`.PresenceState`
When the asynchronous context is entered (see :pep:`492`), the client is
connected. This blocks until the client has finished connecting and the
stream is established. If the client takes longer than `timeout` to
connect, :class:`TimeoutError` is raised and the client is stopped. The
context manager returns the :attr:`~.Client.stream` of the client.
When the context is exited, the client is disconnected and the context
manager waits for the client to cleanly shut down the stream.
If the client is already connected when the context is entered, the
connection is re-used and not shut down when the context is entered, but
leaving the context still disconnects the client.
If the `presence` refers to an available presence, the
:class:`.PresenceServer` is :meth:`~.Client.summon`\\ -ed on the `client`.
The presence is set using :meth:`~.PresenceServer.set_presence` (clearing
the :attr:`~.PresenceServer.status` and resetting
:attr:`~.PresenceServer.priority` to 0) before the client is connected. If
the client is already connected, the presence is set when the context is
entered.
.. deprecated:: 0.8
The use of the `presence` argument is deprecated. The deprecation will
happen in two phases:
1. Until (but not including the release of) 1.0, passing a presence
state which refers to an available presence will emit
:class:`DeprecationWarning`. This *includes* the default of the
argument, so unless an unavailable presence state is passed
explicitly, all uses of :class:`UseConnected` emit that warning.
2. Starting with 1.0, passing an available presence will raise
:class:`ValueError`.
3. Starting with a to-be-determined release after 1.0, passing the
`presence` argument at all will raise :class:`TypeError`.
Users which previously used the `presence` argument should use the
:class:`.PresenceServer` service on the client and set the presence
before using the context manager instead.
.. autoattribute:: presence
See the description of the `presence` argument.
.. deprecated:: 0.8
Using this attribute (for reading or writing) is deprecated and emits
a deprecation warning.
.. autoattribute:: timeout
See the description of the `timeout` argument.
.. deprecated:: 0.8
Using this attribute (for reading or writing) is deprecated and emits
a deprecation warning.
"""
def __init__(self, client, *,
timeout=None,
presence=structs.PresenceState(True)):
super().__init__()
self._client = client
self._timeout = timeout
self._presence = presence
if presence.available:
warnings.warn(
"using an available presence state for UseConnected is"
" deprecated and will raise ValueError as of 1.0",
DeprecationWarning,
stacklevel=1,
)
@property
def timeout(self):
warnings.warn(
"the timeout attribute is deprecated and will be removed in 1.0",
DeprecationWarning,
stacklevel=1,
)
return self._timeout
@timeout.setter
def timeout(self, value):
warnings.warn(
"the timeout attribute is deprecated and will be removed in 1.0",
DeprecationWarning,
stacklevel=1,
)
self._timeout = value
@property
def presence(self):
warnings.warn(
"the presence attribute is deprecated and will be removed in 1.0",
DeprecationWarning,
stacklevel=1,
)
return self._presence
@presence.setter
def presence(self, value):
warnings.warn(
"the presence attribute is deprecated and will be removed in 1.0",
DeprecationWarning,
stacklevel=1,
)
self._presence = value
async def __aenter__(self):
if self._presence.available:
svc = self._client.summon(
mod_presence.PresenceServer
)
svc.set_presence(self._presence)
if self._client.established:
return self._client.stream
conn_future = asyncio.Future()
self._client.on_stream_established.connect(
conn_future,
self._client.on_stream_established.AUTO_FUTURE,
)
self._client.on_failure.connect(
conn_future,
self._client.on_failure.AUTO_FUTURE,
)
if not self._client.running:
self._client.start()
if self._timeout is not None:
try:
await asyncio.wait_for(
conn_future,
self._timeout.total_seconds(),
)
except asyncio.TimeoutError:
self._client.stop()
raise TimeoutError()
else:
await conn_future
return self._client.stream
async def __aexit__(self, exc_type, exc_value, exc_traceback):
if not self._client.running:
return
disconn_future = asyncio.Future()
self._client.on_stopped.connect(
disconn_future,
self._client.on_stopped.AUTO_FUTURE,
)
self._client.on_failure.connect(
disconn_future,
self._client.on_failure.AUTO_FUTURE,
)
self._client.stop()
try:
await disconn_future
except Exception:
# we don’t want to re-raise that; the stream is dead, goal
# achieved.
pass
aioxmpp/nonza.py 0000664 0000000 0000000 00000044547 14160146213 0014246 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: nonza.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.nonza` --- Non-stanza stream-level XSOs (Nonzas)
###############################################################
This module contains XSO models for stream-level elements which are not
stanzas. Since :xep:`0360`, these are called "nonzas".
.. versionchanged:: 0.5
Before version 0.5, this module was called :mod:`aioxmpp.stream_xsos`.
General XSOs
============
.. autoclass:: StreamError()
.. autoclass:: StreamFeatures()
StartTLS related XSOs
=====================
.. autoclass:: StartTLSXSO()
.. autoclass:: StartTLSFeature()
.. autoclass:: StartTLS()
.. autoclass:: StartTLSProceed()
.. autoclass:: StartTLSFailure()
SASL related XSOs
=================
.. autoclass:: SASLXSO
.. autoclass:: SASLAuth
.. autoclass:: SASLChallenge
.. autoclass:: SASLResponse
.. autoclass:: SASLFailure
.. autoclass:: SASLSuccess
.. autoclass:: SASLAbort
Stream management related XSOs
==============================
.. autoclass:: SMXSO()
.. autoclass:: SMRequest()
.. autoclass:: SMAcknowledgement()
.. autoclass:: SMEnable()
.. autoclass:: SMEnabled()
.. autoclass:: SMResume()
.. autoclass:: SMResumed()
"""
import itertools
import warnings
from . import xso, errors
from .utils import namespaces
class StreamError(xso.XSO):
"""
XSO representing a stream error.
.. attribute:: text
The text content of the stream error.
.. attribute:: condition
The RFC 6120 stream error condition.
"""
TAG = (namespaces.xmlstream, "error")
DECLARE_NS = {}
text = xso.ChildText(
tag=(namespaces.streams, "text"),
attr_policy=xso.UnknownAttrPolicy.DROP,
default=None,
declare_prefix=None
)
condition_obj = xso.Child(
[member.xso_class for member in errors.StreamErrorCondition],
required=True,
)
def __init__(self,
condition=errors.StreamErrorCondition.UNDEFINED_CONDITION,
text=None):
super().__init__()
if not isinstance(condition, errors.StreamErrorCondition):
condition = errors.StreamErrorCondition(condition)
warnings.warn(
"as of aioxmpp 1.0, stream error conditions must be members "
"of the aioxmpp.errors.StreamErrorCondition enumeration",
DeprecationWarning,
stacklevel=2,
)
self.condition_obj = condition.xso_class()
self.text = text
@property
def condition(self):
return self.condition_obj.enum_member
@condition.setter
def condition(self, value):
if not isinstance(value, errors.StreamErrorCondition):
value = errors.StreamErrorCondition(value)
warnings.warn(
"as of aioxmpp 1.0, stream error conditions must be members "
"of the aioxmpp.errors.StreamErrorCondition enumeration",
DeprecationWarning,
stacklevel=2,
)
if self.condition == value:
return
self.condition_obj = value.xso_class()
@classmethod
def from_exception(cls, exc):
instance = cls()
instance.text = exc.text
instance.condition = exc.condition
return instance
def to_exception(self):
return errors.StreamError(
condition=self.condition,
text=self.text
)
class StreamFeatures(xso.XSO):
"""
XSO for collecting the supported stream features the remote advertises.
To register a stream feature, use :meth:`register_child` with the
:attr:`features` descriptor. A more fancy way to do the same thing is to
use the :meth:`as_feature_class` classmethod as decorator for your feature
XSO class.
Adding new feature classes:
.. automethod:: as_feature_class
Querying features:
.. method:: stream_features[FeatureClass]
Obtain the first feature XSO which matches the `FeatureClass`. If no
such XSO is contained in the :class:`StreamFeatures` instance
`stream_features`, :class:`KeyError` is raised.
.. method:: stream_features[FeatureClass] = feature
Replace the stream features belonging to the given `FeatureClass` with
the `feature` XSO.
If the `FeatureClass` does not match the type of the `feature` XSO, a
:class:`TypeError` is raised.
It is legal to leave the FeatureClass out by specifying ``...``
instead. In that case, the class is auto-detected from the `feature`
object assigned.
.. method:: del stream_features[FeatureClass]
If any feature of the given `FeatureClass` type is in the
`stream_features`, they are all removed.
Otherwise, :class:`KeyError` is raised, to stay consistent with other
mapping-like types.
.. automethod:: get_feature
.. automethod:: has_feature
"""
TAG = (namespaces.xmlstream, "features")
DECLARE_NS = {
None: namespaces.xmlstream
}
# we drop unknown children
UNKNOWN_CHILD_POLICY = xso.UnknownChildPolicy.DROP
features = xso.ChildMap([])
cruft = xso.Collector()
@classmethod
def as_feature_class(cls, other_cls):
cls.register_child(cls.features, other_cls)
return other_cls
@classmethod
def is_feature(cls, other_cls):
return (cls.CHILD_MAP.get(other_cls.TAG, None) is
cls.features.xq_descriptor)
def __getitem__(self, feature_cls):
try:
return self.features[feature_cls.TAG][0]
except IndexError:
raise KeyError(feature_cls) from None
def __setitem__(self, feature_cls, feature):
if feature_cls is Ellipsis:
feature_cls = type(feature)
if not isinstance(feature, feature_cls):
raise ValueError("incorrect XSO class supplied")
self.features[feature_cls.TAG][:] = [feature]
def __delitem__(self, feature_cls):
items = self.features[feature_cls.TAG]
if not items:
raise KeyError(feature_cls)
items.clear()
def __contains__(self, other):
raise TypeError("membership test not supported")
def has_feature(self, feature_cls):
"""
Return :data:`True` if the stream features contain a feature of the
given `feature_cls` type. :data:`False` is returned otherwise.
"""
return feature_cls.TAG in self.features
def get_feature(self, feature_cls, default=None):
"""
If a feature of the given `feature_cls` type is contained in the
current stream features set, the first such instance is returned.
Otherwise, `default` is returned.
"""
try:
return self[feature_cls]
except KeyError:
return default
def __iter__(self):
return itertools.chain(*self.features.values())
class StartTLSXSO(xso.XSO):
"""
Base class for starttls related XSOs.
This base class merely defines the namespaces to declare when serialising
the derived XSOs
"""
DECLARE_NS = {None: namespaces.starttls}
@StreamFeatures.as_feature_class
class StartTLSFeature(StartTLSXSO):
"""
Start TLS capability stream feature
"""
TAG = (namespaces.starttls, "starttls")
class Required(xso.XSO):
TAG = (namespaces.starttls, "required")
required = xso.Child([Required], required=False)
class StartTLS(StartTLSXSO):
"""
XSO indicating that the client wants to start TLS now.
"""
TAG = (namespaces.starttls, "starttls")
class StartTLSFailure(StartTLSXSO):
"""
Server refusing to start TLS.
"""
TAG = (namespaces.starttls, "failure")
class StartTLSProceed(StartTLSXSO):
"""
Server allows start TLS.
"""
TAG = (namespaces.starttls, "proceed")
class SASLXSO(xso.XSO):
DECLARE_NS = {
None: namespaces.sasl
}
class SASLAuth(SASLXSO):
"""
Start SASL authentication.
.. attribute:: mechanism
The mechanism to authenticate with.
.. attribute:: payload
For mechanisms which use an initial client-supplied payload, this can be
a string. It is automatically encoded as base64 according to the XMPP
SASL specification.
"""
TAG = (namespaces.sasl, "auth")
mechanism = xso.Attr("mechanism")
payload = xso.Text(
type_=xso.Base64Binary(empty_as_equal=True),
default=None
)
def __init__(self, mechanism, payload=None):
super().__init__()
self.mechanism = mechanism
self.payload = payload
class SASLChallenge(SASLXSO):
"""
A SASL challenge.
.. attribute:: payload
The (decoded) SASL payload as :class:`bytes`. Base64 en/decoding is
handled by the XSO stack.
"""
TAG = (namespaces.sasl, "challenge")
payload = xso.Text(
type_=xso.Base64Binary(empty_as_equal=True),
)
def __init__(self, payload):
super().__init__()
self.payload = payload
class SASLResponse(SASLXSO):
"""
A SASL response.
.. attribute:: payload
The (decoded) SASL payload as :class:`bytes`. Base64 en/decoding is
handled by the XSO stack.
"""
TAG = (namespaces.sasl, "response")
payload = xso.Text(
type_=xso.Base64Binary(empty_as_equal=True)
)
def __init__(self, payload):
super().__init__()
self.payload = payload
class SASLFailure(SASLXSO):
"""
Indication of SASL failure.
.. attribute:: condition
The condition which caused the authentication to fail.
.. attribute:: text
Optional human-readable text.
"""
TAG = (namespaces.sasl, "failure")
condition = xso.ChildTag(
tags=[
"aborted",
"account-disabled",
"credentials-expired",
"encryption-required",
"incorrect-encoding",
"invalid-authzid",
"invalid-mechanism",
"malformed-request",
"mechanism-too-weak",
"not-authorized",
"temporary-auth-failure",
],
default_ns=namespaces.sasl,
allow_none=False,
declare_prefix=None,
)
text = xso.ChildText(
(namespaces.sasl, "text"),
attr_policy=xso.UnknownAttrPolicy.DROP,
default=None,
declare_prefix=None
)
def __init__(self, condition=(namespaces.sasl, "temporary-auth-failure")):
super().__init__()
self.condition = condition
class SASLSuccess(SASLXSO):
"""
Indication of SASL success, with optional final payload supplied by the
server.
.. attribute:: payload
The (decoded) SASL payload. Base64 en/decoding is handled by the XSO
stack.
"""
TAG = (namespaces.sasl, "success")
payload = xso.Text(
type_=xso.Base64Binary(empty_as_equal=True),
default=None
)
class SASLAbort(SASLXSO):
"""
Request to abort the SASL authentication.
"""
TAG = (namespaces.sasl, "abort")
class SMXSO(xso.XSO):
"""
Base class for stream-management related XSOs.
This base class merely defines the namespaces to declare when serializing
the data.
"""
DECLARE_NS = {
None: namespaces.stream_management
}
@StreamFeatures.as_feature_class
class StreamManagementFeature(SMXSO):
"""
Stream management stream feature
"""
TAG = (namespaces.stream_management, "sm")
class Required(xso.XSO):
TAG = (namespaces.stream_management, "required")
class Optional(xso.XSO):
TAG = (namespaces.stream_management, "optional")
required = xso.Child([Required])
optional = xso.Child([Optional])
class SMRequest(SMXSO):
"""
A request for an SM acknowledgement (see :class:`SMAcknowledgement`).
"""
TAG = (namespaces.stream_management, "r")
class SMAcknowledgement(SMXSO):
"""
Response to a :class:`SMRequest`.
.. attribute:: counter
The counter as received by the remote side.
"""
TAG = (namespaces.stream_management, "a")
counter = xso.Attr(
"h",
type_=xso.Integer()
)
def __init__(self, counter=0, **kwargs):
super().__init__(**kwargs)
self.counter = counter
def __repr__(self):
return "<{}.{} counter={} at 0x{:x}>".format(
type(self).__module__,
type(self).__qualname__,
self.counter,
id(self),
)
class SMEnable(SMXSO):
"""
Request to enable stream management.
.. attribute:: resume
Set this to :data:`True` to request the capability of resuming the
stream later.
.. attribute:: max_
Maximum time, as integer in seconds, for which the stream should be
resumable after the connection dropped. Only relevant if
:attr:`resume` is true and may be overridden by the server.
.. versionadded:: 0.9
"""
TAG = (namespaces.stream_management, "enable")
resume = xso.Attr(
"resume",
type_=xso.Bool(),
default=False
)
max_ = xso.Attr(
"max",
type_=xso.Integer(),
default=None,
validate=xso.ValidateMode.ALWAYS,
validator=xso.NumericRange(min_=0),
)
def __init__(self, resume=False, max_=None):
super().__init__()
self.resume = resume
self.max_ = max_
def __repr__(self):
return "<{}.{} resume={} max={} at 0x{:x}>".format(
type(self).__module__,
type(self).__qualname__,
self.resume,
self.max_,
id(self),
)
class SMEnabled(SMXSO):
"""
Response to a :class:`SMEnable` request.
.. attribute:: resume
If :data:`True`, the peer allows resumption of the stream.
.. attribute:: id_
The SM-ID of the stream. This is required to resume later.
.. attribute:: location
A hostname-port pair which defines to which host the client shall
connect to resume the stream.
.. attribute:: max_
Maximum time, as integer in seconds, for which the stream will be
resumable after the connection dropped. Only relevant if
:attr:`resume` is true.
"""
TAG = (namespaces.stream_management, "enabled")
resume = xso.Attr(
"resume",
type_=xso.Bool(),
default=False
)
id_ = xso.Attr("id", default=None)
location = xso.Attr(
"location",
type_=xso.ConnectionLocation(),
default=None
)
max_ = xso.Attr(
"max",
type_=xso.Integer(),
default=None,
validate=xso.ValidateMode.ALWAYS,
validator=xso.NumericRange(min_=0),
)
def __init__(self,
resume=False,
id_=None,
location=None,
max_=None):
super().__init__()
self.resume = resume
self.id_ = id_
self.location = location
self.max_ = max_
def __repr__(self):
return (
"<{}.{} resume={} id={!r} location={!r} max={} at 0x{:x}>".format(
type(self).__module__,
type(self).__qualname__,
self.resume,
self.id_,
self.location,
self.max_,
id(self),
)
)
class SMResume(SMXSO):
"""
Request resumption of a previously interrupted SM stream.
.. attribute:: counter
Set this to the value of the local incoming stanza counter.
.. attribute:: previd
Set this to the SM-ID, as received in :class:`SMEnabled`.
"""
TAG = (namespaces.stream_management, "resume")
counter = xso.Attr(
"h",
type_=xso.Integer()
)
previd = xso.Attr("previd")
def __init__(self, counter, previd):
super().__init__()
self.counter = counter
self.previd = previd
def __repr__(self):
return "<{}.{} counter={} previd={!r} at 0x{:x}>".format(
type(self).__module__,
type(self).__qualname__,
self.counter,
self.previd,
id(self),
)
class SMResumed(SMXSO):
"""
Notification that SM resumption was successful, in response to
:class:`SMResume`.
.. attribute:: counter
The stanza counter of the remote side.
.. attribute:: previd
The SM-ID of the stream.
"""
TAG = (namespaces.stream_management, "resumed")
counter = xso.Attr(
"h",
type_=xso.Integer())
previd = xso.Attr("previd")
def __init__(self, counter, previd):
super().__init__()
self.counter = counter
self.previd = previd
def __repr__(self):
return "<{}.{} counter={} previd={!r} at 0x{:x}>".format(
type(self).__module__,
type(self).__qualname__,
self.counter,
self.previd,
id(self),
)
class SMFailed(SMXSO):
"""
Server response to :class:`SMEnable` or :class:`SMResume` if stream
management fails.
"""
TAG = (namespaces.stream_management, "failed")
condition = xso.Child(
[member.xso_class for member in errors.ErrorCondition],
required=True,
)
counter = xso.Attr(
"h",
default=None,
type_=xso.Integer(),
)
def __init__(self,
condition=errors.ErrorCondition.UNDEFINED_CONDITION,
counter=None,
**kwargs):
super().__init__(**kwargs)
self.condition = condition.to_xso()
self.counter = counter
def __repr__(self):
return "<{}.{} condition={!r} at 0x{:x}>".format(
type(self).__module__,
type(self).__qualname__,
self.condition,
id(self),
)
aioxmpp/pep/ 0000775 0000000 0000000 00000000000 14160146213 0013315 5 ustar 00root root 0000000 0000000 aioxmpp/pep/__init__.py 0000664 0000000 0000000 00000003361 14160146213 0015431 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.pep` --- PEP support (:xep:`0163`)
#################################################
This module provides support for using the :xep:`Personal Eventing
Protocol <163>` subset of :xep:`Publish-Subscribe <60>` comfortably.
This protocol does not define any XSOs since PEP reuses the protocol
defined by PubSub.
.. note:: Splitting PEP services into client and server parts is not
well supported, since only one claim per PEP node can be made.
.. note:: Payload classes which are to be used with PEP *must* be
registered with :func:`aioxmpp.pubsub.xso.as_payload_class`.
.. currentmodule:: aioxmpp
.. autoclass:: PEPClient
.. currentmodule:: aioxmpp.pep
.. autoclass:: register_pep_node
.. module:: aioxmpp.pep.service
.. autoclass:: RegisteredPEPNode()
.. currentmodule:: aioxmpp.pep
"""
from .service import (PEPClient, register_pep_node) # NOQA: F401
aioxmpp/pep/service.py 0000664 0000000 0000000 00000036733 14160146213 0015343 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import contextlib
import weakref
import aioxmpp
import aioxmpp.service as service
import aioxmpp.callbacks as callbacks
class PEPClient(service.Service):
"""
:class:`PEPClient` simplifies working with PEP services.
.. versionchanged:: 0.10
Before version 0.10, this service did not depend on
:class:`aioxmpp.EntityCapsService`. This was surprising to users
because the PEP relies on the functionality provided by the protocols
implemented by :class:`~aioxmpp.EntityCapsService` to provide key
features. With the release 0.10, :class:`~aioxmpp.EntityCapsService` is
a dependency of this service. For backward compatibility, your
application should still :meth:`aioxmpp.Client.summon` the
:class:`~aioxmpp.EntityCapsService` explicitly.
Compared to :class:`~aioxmpp.PubSubClient` it supports automatic
checking for server support, a stream-lined API. It is intended to
make PEP things easy. If you need more fine-grained control or do
things which are not usually handled by the defaults when using PEP, use
:class:`~aioxmpp.PubSubClient` directly.
See :class:`register_pep_node` for the high-level interface for
claiming a PEP node and receiving event notifications.
There also is a low-level interface for claiming nodes:
.. automethod:: is_claimed
.. automethod:: claim_pep_node
Further we have a convenience method for publishing items in the client's
PEP service:
.. automethod:: publish
Use the :class:`aioxmpp.PubSubClient` For explicit subscription
and unsubscription .
"""
ORDER_AFTER = [
aioxmpp.DiscoClient,
aioxmpp.DiscoServer,
aioxmpp.PubSubClient,
aioxmpp.EntityCapsService,
]
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._pubsub = self.dependencies[aioxmpp.PubSubClient]
self._disco_client = self.dependencies[aioxmpp.DiscoClient]
self._disco_server = self.dependencies[aioxmpp.DiscoServer]
self._pep_node_claims = weakref.WeakValueDictionary()
def is_claimed(self, node):
"""
Return whether `node` is claimed.
"""
return node in self._pep_node_claims
def claim_pep_node(self, node_namespace, *,
register_feature=True, notify=False):
"""
Claim node `node_namespace`.
:param node_namespace: the pubsub node whose events shall be
handled.
:param register_feature: Whether to publish the `node_namespace`
as feature.
:param notify: Whether to register the ``+notify`` feature to
receive notification without explicit subscription.
:raises RuntimeError: if a handler for `node_namespace` is already
set.
:returns: a :class:`~aioxmpp.pep.service.RegisteredPEPNode` instance
representing the claim.
.. seealso::
:class:`aioxmpp.pep.register_pep_node`
a descriptor which can be used with
:class:`~aioxmpp.service.Service` subclasses to claim a PEP node
automatically.
This registers `node_namespace` as feature for service discovery
unless ``register_feature=False`` is passed.
.. note::
For `notify` to work, it is required that
:class:`aioxmpp.EntityCapsService` is loaded and that presence is
re-sent soon after
:meth:`~aioxmpp.EntityCapsService.on_ver_changed` fires. See the
documentation of the class and the signal for details.
"""
if node_namespace in self._pep_node_claims:
raise RuntimeError(
"claiming already claimed node"
)
registered_node = RegisteredPEPNode(
self,
node_namespace,
register_feature=register_feature,
notify=notify,
)
finalizer = weakref.finalize(
registered_node,
weakref.WeakMethod(registered_node._unregister)
)
# we cannot guarantee that disco is not cleared up already,
# so we do not unclaim the feature on exit
finalizer.atexit = False
self._pep_node_claims[node_namespace] = registered_node
return registered_node
def _unclaim(self, node_namespace):
self._pep_node_claims.pop(node_namespace)
async def available(self):
"""
Check whether we have a PEP identity associated with our account.
"""
disco_info = await self._disco_client.query_info(
self.client.local_jid.bare()
)
for item in disco_info.identities.filter(attrs={"category": "pubsub"}):
if item.type_ == "pep":
return True
return False
async def _check_for_pep(self):
# XXX: should this be done when the stream connects
# and we use the cached result later on (i.e. disable
# the PEP service if the server does not support PEP)
if not await self.available():
raise RuntimeError("server does not support PEP")
@service.depsignal(aioxmpp.PubSubClient, "on_item_published")
def _handle_pubsub_publish(self, jid, node, item, *, message=None):
try:
registered_node = self._pep_node_claims[node]
except KeyError:
return
# PEP requires, that notifies contain the data and that
# the namespace of the payload corresponds to the node,
# by enforcing this here we protect the consumers of
# the signal.
if (item.registered_payload is None or
item.registered_payload.TAG[0] != node):
self.logger.debug(
"ignoring notify from misconfigured PEP node %s at %s",
node, jid)
return
registered_node.on_item_publish(jid, node, item, message=message)
async def publish(self, node, data, *, id_=None, access_model=None):
"""
Publish an item `data` in the PubSub node `node` on the
PEP service associated with the user's JID.
:param node: The PubSub node to publish to.
:param data: The item to publish.
:type data: An XSO representing the paylaod.
:param id_: The id the published item shall have.
:param access_model: The access model to enforce on the node. Defaults
to not enforcing any access model.
:returns: The PubSub id of the published item or
:data:`None` if it is unknown.
:raises RuntimeError: if PEP is not supported.
:raises RuntimeError: if `access_model` is set and `publish_options` is
not supported by the server
If no `id_` is given it is generated by the server (and may be
returned).
`access_model` defines a pre-condition on the access model used for the
`node`. The valid values depend on the service; commonly useful
``"presence"`` (the default for PEP; allows access to anyone who can
receive the presence) and ``"whitelist"`` (allows access only to a
whitelist (which defaults to the own account only)).
"""
publish_options = None
def autocreate_publish_options():
nonlocal publish_options
if publish_options is None:
publish_options = aioxmpp.forms.Data(
aioxmpp.forms.DataType.SUBMIT
)
publish_options.fields.append(
aioxmpp.forms.Field(
type_=aioxmpp.forms.FieldType.HIDDEN,
var="FORM_TYPE",
values=[
"http://jabber.org/protocol/pubsub#publish-options"
]
)
)
return publish_options
if access_model is not None:
autocreate_publish_options()
publish_options.fields.append(aioxmpp.forms.Field(
var="pubsub#access_model",
values=[access_model],
))
await self._check_for_pep()
return await self._pubsub.publish(
None, node, data, id_=id_,
publish_options=publish_options
)
class RegisteredPEPNode:
"""
Handle for registered PEP nodes.
*Never* instantiate this class yourself. Use
:class:`~aioxmpp.pep.register_pep_node` or
:attr:`~aioxmpp.pep.PEPClient.claim_pep_node` to obtain instances.
You have to keep a reference to the instance to
uphold the claim, when a instance is garbage
collected it is closed automatically. It is not enough to have a
callback registered! It is strongly recommended to explicitly
close the registered node if it is no longer needed or to use the
:class:`~aioxmpp.pep.register_pep_node` descriptor for automatic
life-cycle handling.
.. signal:: on_item_publish(jid, node, item, message=None)
Fires when an event is received for this PEP node. The arguments
are as for :attr:`aioxmpp.PubSubClient.on_item_publish`.
.. warning:: Empty notifications and notifications whose
payload namespace does not match the node
namespace are filtered and will not cause
this signal to fire (since they do not match the
PEP specification).
.. autoattribute:: notify
.. autoattribute:: feature_registered
.. automethod:: close
"""
def __init__(self, pep_service, node, register_feature, notify):
self._pep_service = pep_service
self._node = node
self._feature_registered = register_feature
self._notify = notify
self._closed = False
if self._feature_registered:
self._register_feature()
if self._notify:
self._register_notify()
on_item_publish = callbacks.Signal()
def _register_feature(self):
self._pep_service._disco_server.register_feature(self._node)
self._feature_registered = True
def _unregister_feature(self):
self._pep_service._disco_server.unregister_feature(self._node)
self._feature_registered = False
def _register_notify(self):
self._pep_service._disco_server.register_feature(self._notify_feature)
self._notify = True
def _unregister_notify(self):
self._pep_service._disco_server.unregister_feature(
self._notify_feature)
self._notify = False
def _unregister(self):
if self._notify:
self._unregister_notify()
if self._feature_registered:
self._unregister_feature()
def close(self):
"""
Unclaim the PEP node and unregister the registered features.
It is not necessary to call close if this claim is managed by
:class:`~aioxmpp.pep.register_pep_node`.
"""
if self._closed:
return
self._closed = True
self._pep_service._unclaim(self.node_namespace)
self._unregister()
@property
def node_namespace(self):
"""The claimed node namespace"""
return self._node
@property
def _notify_feature(self):
return self._node + "+notify"
@property
def notify(self):
"""
Whether we have enabled the ``+notify`` feature to automatically
receive notifications.
When setting this property the feature is registered and
unregistered appropriately.
.. note::
For `notify` to work, it is required that
:class:`aioxmpp.EntityCapsService` is loaded and that presence is
re-sent soon after
:meth:`~aioxmpp.EntityCapsService.on_ver_changed` fires. See the
documentation of the class and the signal for details.
"""
return self._notify
@notify.setter
def notify(self, value):
if self._closed:
raise RuntimeError(
"modifying a closed RegisteredPEPNode is forbidden"
)
# XXX: do we want to do strict type checking here?
if bool(value) == bool(self._notify):
return
if self._notify:
self._unregister_notify()
else:
self._register_notify()
@property
def feature_registered(self):
"""
Whether we have registered the node namespace as feature.
When setting this property the feature is registered and
unregistered appropriately.
"""
return self._feature_registered
@feature_registered.setter
def feature_registered(self, value):
if self._closed:
raise RuntimeError(
"modifying a closed RegisteredPEPNode is forbidden"
)
# XXX: do we want to do strict type checking here?
if bool(value) == bool(self._feature_registered):
return
if self._feature_registered:
self._unregister_feature()
else:
self._register_feature()
class register_pep_node(service.Descriptor):
"""
Service descriptor claiming a PEP node.
:param node_namespace: The PubSub payload namespace to handle.
:param register_feature: Whether to register the node namespace as feature.
:param notify: Whether to register for notifications.
If `notify` is :data:`True` it registers a ``+notify`` feature,
for automatic pubsub subscription.
The value of the descriptor on an instance is the
:class:`~aioxmpp.pep.service.RegisteredPEPNode` object representing the
claim.
"""
def __init__(self, node_namespace, *, register_feature=True,
notify=False):
super().__init__()
self._node_namespace = node_namespace
self._notify = notify
self._register_feature = register_feature
@property
def node_namespace(self):
"""
The node namespace to request notifications for.
"""
return self._node_namespace
@property
def register_feature(self):
"""
Whether we register the node namespace as feature.
"""
return self._register_feature
@property
def notify(self):
"""
Whether we register the ``+nofity`` feature.
"""
return self._notify
@property
def required_dependencies(self):
return [PEPClient]
@contextlib.contextmanager
def init_cm(self, instance):
pep_client = instance.dependencies[PEPClient]
claim = pep_client.claim_pep_node(
self._node_namespace,
register_feature=self._register_feature,
notify=self._notify,
)
yield claim
claim.close()
@property
def value_type(self):
return RegisteredPEPNode
aioxmpp/ping/ 0000775 0000000 0000000 00000000000 14160146213 0013466 5 ustar 00root root 0000000 0000000 aioxmpp/ping/__init__.py 0000664 0000000 0000000 00000003130 14160146213 0015574 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.ping` --- XMPP Ping (:xep:`199`)
###############################################
XMPP Ping is a ping on the XMPP protocol level. It can be used to detect
connection liveness (although :class:`aioxmpp.stream.StanzaStream` and thus
:class:`aioxmpp.Client` does that for you) and connectivity/availablility of
remote domains.
Service
=======
.. currentmodule:: aioxmpp
.. autoclass:: PingService()
.. currentmodule:: aioxmpp.ping
.. autofunction:: ping
XSOs
====
Sometimes it is useful to send a ping manually instead of relying on the
:class:`Service`. For this, the :class:`Ping` IQ payload can be used.
.. autoclass:: Ping()
"""
from .service import PingService, ping # NOQA: F401
from .xso import Ping # NOQA: F401
aioxmpp/ping/service.py 0000664 0000000 0000000 00000007434 14160146213 0015510 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import aioxmpp.disco
import aioxmpp.service
import aioxmpp.structs
from aioxmpp.utils import namespaces
from . import xso as ping_xso
class PingService(aioxmpp.service.Service):
"""
Service implementing XMPP Ping (:xep:`199`).
This service implements the response to XMPP Pings and provides a method
to send pings.
.. automethod:: ping
"""
ORDER_AFTER = [
aioxmpp.disco.DiscoServer,
]
_ping_feature = aioxmpp.disco.register_feature(
namespaces.xep0199_ping
)
@aioxmpp.service.iq_handler(aioxmpp.structs.IQType.GET, ping_xso.Ping)
async def handle_ping(self, request):
return ping_xso.Ping()
async def ping(self, peer):
"""
Wrapper around :func:`aioxmpp.ping.ping`.
**When to use this wrapper vs. the global function:** Using this method
has the side effect that the application will start to respond to
:xep:`199` pings. While this is not a security issue per se (as
responding to :xep:`199` pings only changes the format of the reply,
not the fact that a reply is sent from the client), it may not be
desirable under all circumstances.
So especially when developing a Service which does not *require* that
the application replies to pings (for example, when implementing a
stream or group chat aliveness check), it is preferable to use the
global function.
When implementing an application where it is desirable to reply to
pings, using this wrapper is fine.
In general, aioxmpp services should avoid depending on this service.
(The decision essentially boils down to "summon this service or not?",
and it is not a decision aioxmpp should make for the application unless
necessary for compliance.)
.. versionchanged:: 0.11
Converted to a shim wrapper.
"""
return await ping(self.client, peer)
async def ping(client, peer):
"""
Ping a peer.
:param peer: The peer to ping.
:type peer: :class:`aioxmpp.JID`
:raises aioxmpp.errors.XMPPError: as received
Send a :xep:`199` ping IQ to `peer` and wait for the reply.
This is a low-level version of :meth:`aioxmpp.PingService.ping`.
**When to use this function vs. the service method:** See
:meth:`aioxmpp.PingService.ping`.
.. note::
If the peer does not support :xep:`199`, they will respond with
a ``cancel`` ``service-unavailable`` error. However, some
implementations return a ``cancel`` ``feature-not-implemented``
error instead. Callers should be prepared for the
:class:`aioxmpp.XMPPCancelError` exceptions in those cases.
.. versionchanged:: 0.11
Extracted this helper from :class:`aioxmpp.PingService`.
"""
iq = aioxmpp.IQ(
to=peer,
type_=aioxmpp.IQType.GET,
payload=ping_xso.Ping()
)
await client.send(iq)
aioxmpp/ping/xso.py 0000664 0000000 0000000 00000002273 14160146213 0014655 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp
import aioxmpp.xso
from aioxmpp.utils import namespaces
namespaces.xep0199_ping = "urn:xmpp:ping"
@aioxmpp.IQ.as_payload_class
class Ping(aioxmpp.xso.XSO):
"""
Simple XSO to represent an XMPP ping.
It takes no arguments and has no attributes or children.
"""
TAG = (namespaces.xep0199_ping, "ping")
aioxmpp/presence/ 0000775 0000000 0000000 00000000000 14160146213 0014335 5 ustar 00root root 0000000 0000000 aioxmpp/presence/__init__.py 0000664 0000000 0000000 00000002743 14160146213 0016454 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.presence` --- Peer presence bookkeeping
######################################################
This module provides a :class:`.PresenceClient` service to track the presence
of peers, no matter whether they are in the roster or not.
.. versionadded:: 0.4
.. currentmodule:: aioxmpp
.. autoclass:: PresenceClient
.. autoclass:: PresenceServer
.. currentmodule:: aioxmpp.presence
.. class:: Service
Alias of :class:`.PresenceClient`.
.. deprecated:: 0.8
The alias will be removed in 1.0.
"""
from .service import PresenceClient, PresenceServer # NOQA: F401
Service = PresenceClient # NOQA
aioxmpp/presence/service.py 0000664 0000000 0000000 00000034634 14160146213 0016361 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import numbers
import aioxmpp.callbacks
import aioxmpp.service
import aioxmpp.structs
import aioxmpp.xso.model
class PresenceClient(aioxmpp.service.Service):
"""
The presence service tracks all incoming presence information (this does
not include subscription management stanzas, as these are handled by
:mod:`aioxmpp.roster`). It is independent of the roster, as directed
presence is independent of the roster and still needs to be tracked
accordingly.
No method to send directed presence is provided; it would basically just
take a stanza and enqueue it in the clients stream, thus being a mere
wrapper around :meth:`~.Client.send`, without any benefit.
The service provides access to presence information summarized by bare JID
or for each full JID individually. An index over the resources of a bare
JID is available.
If an error presence is received for a JID, it replaces all known presence
information. It is returned for all queries, no matter for which resource
the query is. As soon as a non-error presence is received for any resource
of the bare JID, the error is cleared.
.. automethod:: get_most_available_stanza
.. automethod:: get_peer_resources
.. automethod:: get_stanza
On presence changes of peers, signals are emitted:
.. signal:: on_bare_available(stanza)
Fires when the first resource of a peer becomes available.
.. signal:: on_bare_unavailable(stanza)
Fires when the last resource of a peer becomes unavailable or enters
error state.
.. signal:: on_available(full_jid, stanza)
Fires when a resource becomes available at `full_jid`. The `stanza`
which caused the availability is handed over as second argument.
This signal always fires after :meth:`on_bare_available`.
.. signal:: on_changed(full_jid, stanza)
Fires when a the presence of the resource at `full_jid` changes, but
does not become unavailable or available. The `stanza` which caused the
change is handed over as second argument.
.. signal:: on_unavailable(full_jid, stanza)
Fires when the resource at `full_jid` becomes unavailable, with the
`stanza` causing the unavailability as second argument.
This signal always fires before :meth:`on_bare_unavailable`.
.. note::
If the resource became unavailable due to an error, the `full_jid`
will not match the :attr:`~.stanza.StanzaBase.from_` attribute of the
`stanza`, as the error is coming from the bare JID.
The three signals :meth:`on_available`, :meth:`on_changed` and
:meth:`on_unavailable` never fire for the same stanza.
.. versionadded:: 0.4
.. versionchanged:: 0.8
This class was formerly known as :class:`aioxmpp.presence.Service`. It
is still available under that name, but the alias will be removed in
1.0.
"""
ORDER_AFTER = [
aioxmpp.dispatcher.SimplePresenceDispatcher,
]
on_bare_available = aioxmpp.callbacks.Signal()
on_bare_unavailable = aioxmpp.callbacks.Signal()
on_available = aioxmpp.callbacks.Signal()
on_changed = aioxmpp.callbacks.Signal()
on_unavailable = aioxmpp.callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._presences = {}
def get_most_available_stanza(self, peer_jid):
"""
Obtain the stanza describing the most-available presence of the
contact.
:param peer_jid: Bare JID of the contact.
:type peer_jid: :class:`aioxmpp.JID`
:rtype: :class:`aioxmpp.Presence` or :data:`None`
:return: The presence stanza of the most available resource or
:data:`None` if there is no available resource.
The "most available" resource is the one whose presence state orderest
highest according to :class:`~aioxmpp.PresenceState`.
If there is no available resource for a given `peer_jid`, :data:`None`
is returned.
"""
presences = sorted(
self.get_peer_resources(peer_jid).items(),
key=lambda item: aioxmpp.structs.PresenceState.from_stanza(item[1])
)
if not presences:
return None
return presences[-1][1]
def get_peer_resources(self, peer_jid):
"""
Return a dict mapping resources of the given bare `peer_jid` to the
presence state last received for that resource.
Unavailable presence states are not included. If the bare JID is in a
error state (i.e. an error presence stanza has been received), the
returned mapping is empty.
"""
try:
d = dict(self._presences[peer_jid])
d.pop(None, None)
return d
except KeyError:
return {}
def get_stanza(self, peer_jid):
"""
Return the last presence received for the given bare or full
`peer_jid`. If the last presence was unavailable, the return value is
:data:`None`, as if no presence was ever received.
If no presence was ever received for the given bare JID, :data:`None`
is returned.
"""
try:
return self._presences[peer_jid.bare()][peer_jid.resource]
except KeyError:
pass
try:
return self._presences[peer_jid.bare()][None]
except KeyError:
pass
@aioxmpp.dispatcher.presence_handler(
aioxmpp.structs.PresenceType.AVAILABLE,
None)
@aioxmpp.dispatcher.presence_handler(
aioxmpp.structs.PresenceType.UNAVAILABLE,
None)
@aioxmpp.dispatcher.presence_handler(
aioxmpp.structs.PresenceType.ERROR,
None)
def handle_presence(self, st):
if st.from_ is None:
if st.type_ != aioxmpp.structs.PresenceType.ERROR:
self.logger.debug(
"dropping unhandled presence from account"
)
return
bare = st.from_.bare()
resource = st.from_.resource
if st.type_ == aioxmpp.structs.PresenceType.UNAVAILABLE:
try:
dest_dict = self._presences[bare]
except KeyError:
return
dest_dict.pop(None, None)
if resource in dest_dict:
self.on_unavailable(st.from_, st)
if len(dest_dict) == 1:
self.on_bare_unavailable(st)
del dest_dict[resource]
elif st.type_ == aioxmpp.structs.PresenceType.ERROR:
try:
dest_dict = self._presences[bare]
except KeyError:
pass
else:
for resource in dest_dict.keys():
self.on_unavailable(st.from_.replace(resource=resource),
st)
self.on_bare_unavailable(st)
self._presences[bare] = {None: st}
else:
dest_dict = self._presences.setdefault(bare, {})
dest_dict.pop(None, None)
bare_became_available = not dest_dict
resource_became_available = resource not in dest_dict
dest_dict[resource] = st
if bare_became_available:
self.on_bare_available(st)
if resource_became_available:
self.on_available(st.from_, st)
else:
self.on_changed(st.from_, st)
class PresenceServer(aioxmpp.service.Service):
"""
Manage the presence broadcast by the client.
The :class:`PresenceServer` manages broadcasting and re-broadcasting the
presence of the client as needed.
The presence state is initialised to an unavailable presence. Unavailable
presences are not emitted when the stream is established.
Presence information:
.. autoattribute:: state
.. autoattribute:: status
.. autoattribute:: priority
.. automethod:: make_stanza
Changing/sending/watching presence:
.. automethod:: set_presence
.. automethod:: resend_presence
.. signal:: on_presence_changed()
Emits after the presence has been changed in the
:class:`PresenceServer`.
.. signal:: on_presence_state_changed(new_state)
Emits after the presence *state* has been changed in the
:class:`PresenceServer`.
This signal does not emit if other parts of the presence (such as
priority or status texts) change, while the presence state itself stays
the same.
.. versionadded:: 0.8
"""
on_presence_changed = aioxmpp.callbacks.Signal()
on_presence_state_changed = aioxmpp.callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._state = aioxmpp.PresenceState(False)
self._status = {}
self._priority = 0
client.before_stream_established.connect(
self._before_stream_established
)
async def _before_stream_established(self):
if not self._state.available:
return True
await self.client.send(self.make_stanza())
return True
@property
def state(self):
"""
The currently set presence state (as :class:`aioxmpp.PresenceState`)
which is broadcast when the client connects and when the presence is
re-emitted.
This attribute cannot be written. It does not reflect the actual
presence seen by others. For example when the client is in fact
offline, others will see unavailable presence no matter what is set
here.
"""
return self._state
@property
def status(self):
"""
The currently set textual presence status which is broadcast when the
client connects and when the presence is re-emitted.
This attribute cannot be written. It does not reflect the actual
presence seen by others. For example when the client is in fact
offline, others will see unavailable presence no matter what is set
here.
"""
return self._status
@property
def priority(self):
"""
The currently set priority which is broadcast when the client connects
and when the presence is re-emitted.
This attribute cannot be written. It does not reflect the actual
presence seen by others. For example when the client is in fact
offline, others will see unavailable presence no matter what is set
here.
"""
return self._priority
def make_stanza(self):
"""
Create and return a presence stanza with the current settings.
:return: Presence stanza
:rtype: :class:`aioxmpp.Presence`
"""
stanza = aioxmpp.Presence()
self._state.apply_to_stanza(stanza)
stanza.status.update(self._status)
return stanza
def set_presence(self, state, status={}, priority=0):
"""
Change the presence broadcast by the client.
:param state: New presence state to broadcast
:type state: :class:`aioxmpp.PresenceState`
:param status: New status information to broadcast
:type status: :class:`dict` or :class:`str`
:param priority: New priority for the resource
:type priority: :class:`int`
:return: Stanza token of the presence stanza or :data:`None` if the
presence is unchanged or the stream is not connected.
:rtype: :class:`~.stream.StanzaToken`
If the client is currently connected, the new presence is broadcast
immediately.
`status` must be either a string or something which can be passed to
the :class:`dict` constructor. If it is a string, it is wrapped into a
dict using ``{None: status}``. The mapping must map
:class:`~.structs.LanguageTag` objects (or :data:`None`) to strings.
The information will be used to generate internationalised presence
status information. If you do not need internationalisation, simply use
the string version of the argument.
"""
if not isinstance(priority, numbers.Integral):
raise TypeError(
"invalid priority: got {}, expected integer".format(
type(priority)
)
)
if not isinstance(state, aioxmpp.PresenceState):
raise TypeError(
"invalid state: got {}, expected aioxmpp.PresenceState".format(
type(state),
)
)
if isinstance(status, str):
new_status = {None: status}
else:
new_status = dict(status)
new_priority = int(priority)
emit_state_event = self._state != state
emit_overall_event = (
emit_state_event or
self._priority != new_priority or
self._status != new_status
)
self._state = state
self._status = new_status
self._priority = new_priority
if emit_state_event:
self.on_presence_state_changed()
if emit_overall_event:
self.on_presence_changed()
return self.resend_presence()
def resend_presence(self):
"""
Re-send the currently configured presence.
:return: Stanza token of the presence stanza or :data:`None` if the
stream is not established.
:rtype: :class:`~.stream.StanzaToken`
.. note::
:meth:`set_presence` automatically broadcasts the new presence if
any of the parameters changed.
"""
if self.client.established:
return self.client.enqueue(self.make_stanza())
aioxmpp/private_xml/ 0000775 0000000 0000000 00000000000 14160146213 0015063 5 ustar 00root root 0000000 0000000 aioxmpp/private_xml/__init__.py 0000664 0000000 0000000 00000002543 14160146213 0017200 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.private_xml` – Private XML Storage support (:xep:`0049`)
#######################################################################
This module provides support for storing and retrieving private XML data on
the server as per :xep:`Private XML Storage <49>`.
.. autoclass:: PrivateXMLService
To register payload XSOs for private storage :class:`Query` is
exposed:
.. autoclass:: Query
"""
from .xso import Query # NOQA: F401
from .service import PrivateXMLService # NOQA: F401
aioxmpp/private_xml/service.py 0000664 0000000 0000000 00000004356 14160146213 0017105 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.service as service
from . import xso as private_xml_xso
class PrivateXMLService(service.Service):
"""
Service for handling server side private XML storage.
.. automethod:: get_private_xml
.. automethod:: set_private_xml
"""
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
async def get_private_xml(self, query_xso):
"""
Get the private XML data for the element `query_xso` from the
server.
:param query_xso: the object to retrieve.
:returns: the stored private XML data.
`query_xso` *must* serialize to an empty XML node of the
wanted namespace and type and *must* be registered as private
XML :class:`~private_xml_xso.Query` payload.
"""
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.GET,
payload=private_xml_xso.Query(query_xso)
)
return await self.client.send(iq)
async def set_private_xml(self, xso):
"""
Store the serialization of `xso` on the server as the private XML
data for the namespace of `xso`.
:param xso: the XSO whose serialization is send as private XML data.
"""
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=private_xml_xso.Query(xso)
)
await self.client.send(iq)
aioxmpp/private_xml/xso.py 0000664 0000000 0000000 00000003314 14160146213 0016247 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0049 = "jabber:iq:private"
@aioxmpp.IQ.as_payload_class
class Query(xso.XSO):
"""
The XSO for queries to private XML storage.
.. automethod:: as_payload_class
"""
TAG = (namespaces.xep0049, "query")
registered_payload = xso.Child([], strict=True)
unregistered_payload = xso.Collector()
def __init__(self, payload):
self.registered_payload = payload
@classmethod
def as_payload_class(mycls, xso_class):
"""
Register the given class `xso_class` as possible payload
for private XML storage.
Return `xso_class`, to allow this to be used as a decorator.
"""
mycls.register_child(
Query.registered_payload,
xso_class
)
return xso_class
aioxmpp/protocol.py 0000664 0000000 0000000 00000076422 14160146213 0014757 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: protocol.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.protocol` --- XML Stream implementation
######################################################
This module contains the :class:`XMLStream` class, which implements the XML
stream protocol used by XMPP. It makes extensive use of the :mod:`aioxmpp.xml`
module and the :mod:`aioxmpp.xso` subpackage to parse and serialize XSOs
received and sent on the stream.
In addition, helper functions to work with :class:`XMLStream` instances are
provided; these are not included in the class itself because they provide
additional functionality solely based on the public interface of the
class. Separating them helps with testing.
.. autoclass:: XMLStream
Utilities for XML streams
=========================
.. autofunction:: send_and_wait_for
.. autofunction:: reset_stream_and_get_features
Enumerations
============
.. autoclass:: Mode
.. autoclass:: State
"""
import asyncio
import contextlib
import functools
import logging
from enum import Enum
import xml.sax as sax
import xml.parsers.expat as pyexpat
from . import xml, errors, xso, nonza, stanza, callbacks, statemachine, utils
from .utils import namespaces
logger = logging.getLogger(__name__)
class Mode(Enum):
"""
Possible modes of connection for an XML stream. These define the namespaces
used.
.. attribute:: C2S
A client stream connected to a server. This is the default mode and,
currently, the only available mode.
"""
C2S = namespaces.client
@functools.total_ordering
class State(Enum):
"""
The possible states of a :class:`XMLStream`:
.. attribute:: READY
The initial state; this is the case when no underlying transport is
connected.
.. attribute:: STREAM_HEADER_SENT
After a :class:`asyncio.Transport` calls
:meth:`XMLStream.connection_made` on the xml stream, it sends the stream
header and enters this state.
.. attribute:: OPEN
When the stream header of the peer is received, this state is entered
and the XML stream can be used for sending and receiving XSOs.
.. attribute:: CLOSING
After :meth:`XMLStream.close` is called, this state is entered. We sent
a stream footer and an EOF, if the underlying transport supports
this. We still have to wait for the peer to close the stream.
In this state and all following states, :class:`ConnectionError`
instances are raised whenever an attempt is made to write to the
stream. The exact instance depends on the reason of the closure.
In this state, the stream waits for the remote to send a stream footer
and the connection to shut down. For application purposes, the stream is
already closed.
.. attribute:: CLOSING_STREAM_FOOTER_RECEIVED
At this point, the stream is properly closed on the XML stream
level. This is the point where :meth:`XMLStream.close_and_wait`
returns.
.. attribute:: CLOSED
This state is entered when the connection is lost in any way. This is
the final state.
"""
def __lt__(self, other):
return self.value < other.value
READY = 0
STREAM_HEADER_SENT = 1
OPEN = 2
CLOSING = 3
CLOSING_STREAM_FOOTER_RECEIVED = 4
CLOSED = 6
class DebugWrapper:
def __init__(self, dest, logger):
self.dest = dest
self.logger = logger
if hasattr(dest, "flush"):
self._flush = dest.flush
else:
self._flush = lambda: None
self._pieces = []
self._total_len = 0
self._muted = False
self._written_mute_marker = False
def _emit(self):
self.logger.debug("SENT %r", b"".join(self._pieces))
self._pieces = []
self._total_len = 0
def write(self, data):
if self._muted:
if not self._written_mute_marker:
self._pieces.append(b"")
self._written_mute_marker = True
else:
self._pieces.append(data)
self._total_len += len(data)
result = self.dest.write(data)
if self._total_len >= 4096:
self._emit()
return result
def flush(self):
self._emit()
self._flush()
@contextlib.contextmanager
def mute(self):
self._muted = True
self._written_mute_marker = False
try:
yield
finally:
self._muted = False
class XMLStream(asyncio.Protocol):
"""
XML stream implementation. This is an streaming :class:`asyncio.Protocol`
which translates the received bytes into XSOs.
:param to: Domain of the server the stream connects to.
:type to: :class:`~aioxmpp.JID`
:param features_future: Use :meth:`features_future` instead.
:type features_future: :class:`asyncio.Future`
:param sorted_attributes: Sort attributes deterministically on output
(debug option; not part of the public interface)
:type sorted_attributes: :class:`bool`
:param default_namespace: Set the default namespace to advertise on the
stream header.
:type default_namespace: :class:`str`
:param from_: The value of the from attribute of the stream header.
:tpye from_: :class:`~aioxmpp.JID` or :data:`None`
:param base_logger: Parent logger for this stream
:type base_logger: :class:`logging.Logger`
`to` must identify the remote server to connect to. This is used as the
``to`` attribute on the stream header.
`features_future` may be a future. The XML stream will set the first
:class:`~aioxmpp.nonza.StreamFeatures` node it receives as the result of
the future. The future will also receive any pre-stream-features
exception.
`sorted_attributes` is a testing/debugging option to enable sorted output
of the XML attributes emitted on the stream. See
:class:`~aioxmpp.xml.XMPPXMLGenerator` for details. Do not use outside of
unit testing code, as it has a negative performance impact.
`base_logger` may be a :class:`logging.Logger` instance to use. The XML
stream will create a child called ``XMLStream`` at that logger and use that
child for logging purposes. This eases debugging and allows for
connection-specific loggers.
.. deprecated:: 0.12
Using `features_future` as positional or keyword argument is
deprecated and will be removed in version 1.0. Use
:meth:`features_future` to obtain a future instead.
Receiving XSOs:
.. attribute:: stanza_parser
A :class:`~aioxmpp.xso.XSOParser` instance which is wired to a
:class:`~aioxmpp.xml.XMPPXMLProcessor` which processes the received
bytes.
To receive XSOs over the XML stream, use :attr:`stanza_parser` and
register class callbacks on it using
:meth:`~aioxmpp.xso.XSOParser.add_class`.
.. attribute:: error_handler
This should be assigned a callable, taking two arguments: a
:class:`xso.XSO` instance, which is the partial(!) top-level stream
element and an exception indicating the failure.
Partial here means that it is not guaranteed that anything but the
attributes on the partial XSO itself are there. Any children or text
payload is most likely missing, as it probably caused the error.
.. versionadded:: 0.4
Sending XSOs:
.. automethod:: send_xso
Manipulating stream state:
.. automethod:: starttls
.. automethod:: reset
.. automethod:: close
.. automethod:: abort
Controlling debug output:
.. automethod:: mute
Waiting for stream state changes:
.. automethod:: error_future
.. automethod:: features_future
Monitoring stream aliveness:
.. autoattribute:: deadtime_soft_limit
.. autoattribute:: deadtime_hard_limit
Signals:
.. signal:: on_closing(reason)
A :class:`~aioxmpp.callbacks.Signal` which fires when the underlying
transport of the stream reports an error or when a stream error is
received. The signal is fired with the corresponding exception as the
only argument.
If the stream gets closed by the application without any error, the
argument is :data:`None`.
By the time the callback fires, the stream is already unusable for
sending stanzas. It *may* however still receive stanzas, if the stream
shutdown was initiated by the application and the peer has not yet send
its stream footer.
If the application is not able to handle these stanzas, it is legitimate
to disconnect their handlers from the :attr:`stanza_parser`; the stream
will be able to deal with unhandled top level stanzas correctly at this
point (by ignoring them).
.. signal:: on_deadtime_soft_limit_tripped
Emits when the soft limit dead time has been exceeded.
See :attr:`deadtime_soft_limit` for general information on the timeout
handling.
.. versionadded:: 0.10
Timeouts:
.. attribute:: shutdown_timeout
The maximum time to wait for the peer ```` before
forcing to close the transport and considering the stream closed.
"""
on_closing = callbacks.Signal()
on_deadtime_soft_limit_tripped = callbacks.Signal()
shutdown_timeout = 15
def __init__(self, to,
features_future=None,
sorted_attributes=False,
base_logger=logging.getLogger("aioxmpp"),
default_namespace="jabber:client",
from_=None,
loop=None):
self._to = to
self._from = from_
self._sorted_attributes = sorted_attributes
self._logger = base_logger.getChild("XMLStream")
self._transport = None
self._exception = None
self._loop = loop or asyncio.get_event_loop()
self._features_futures = []
self._error_futures = []
if features_future is not None:
self._features_futures.append(features_future)
self._error_futures.append(features_future)
self._smachine = statemachine.OrderedStateMachine(State.READY)
self._transport_closing = False
self._default_namespace = default_namespace
self._monitor = utils.AlivenessMonitor(self._loop)
self._monitor.on_deadtime_hard_limit_tripped.connect(
self._deadtime_hard_limit_triggered
)
self._monitor.on_deadtime_soft_limit_tripped.connect(
self.on_deadtime_soft_limit_tripped
)
self._closing_future = asyncio.ensure_future(
self._smachine.wait_for(
State.CLOSING
),
loop=loop
)
self._closing_future.add_done_callback(
self._stream_starts_closing
)
self.stanza_parser = xso.XSOParser()
self.stanza_parser.add_class(nonza.StreamError,
self._rx_stream_error)
self.stanza_parser.add_class(nonza.StreamFeatures,
self._rx_stream_features)
self.error_handler = None
def _invalid_transition(self, to, via=None):
text = "invalid state transition: from={} to={}".format(
self._smachine.state,
to)
if via:
text += " (via: {})".format(via)
return RuntimeError(text)
def _invalid_state(self, at=None):
text = "invalid state: {}".format(self._smachine.state)
if at:
text += " (at: {})".format(at)
return RuntimeError(text)
def _close_transport(self):
if self._transport_closing:
return
self._transport_closing = True
self._transport.close()
def _stream_starts_closing(self, task):
exc = self._exception
if exc is None:
exc = ConnectionError("stream shut down")
self.on_closing(self._exception)
for fut in self._error_futures:
if not fut.done():
fut.set_exception(exc)
self._error_futures.clear()
if task.cancelled():
return
if task.exception() is not None:
return
task.result()
def _fail(self, err):
self._exception = err
self.close()
def _require_connection(self, accept_partial=False):
if (self._smachine.state == State.OPEN or
(accept_partial and
self._smachine.state == State.STREAM_HEADER_SENT)):
return
if self._exception:
raise self._exception
raise ConnectionError("xmlstream not connected")
def _rx_exception(self, exc):
if isinstance(exc, stanza.StanzaError):
if self.error_handler:
self.error_handler(exc.partial_obj, exc)
elif isinstance(exc, xso.UnknownTopLevelTag):
if self._smachine.state >= State.CLOSING:
self._logger.info("ignoring unknown top-level tag, "
"we’re closing")
return
raise errors.StreamError(
condition=errors.StreamErrorCondition.UNSUPPORTED_STANZA_TYPE,
text="unsupported stanza: {}".format(
xso.tag_to_str((exc.ev_args[0], exc.ev_args[1]))
)) from None
else:
context = exc.__context__ or exc.__cause__
raise exc from context
def _rx_stream_header(self):
if self._processor.remote_version != (1, 0):
raise errors.StreamError(
errors.StreamErrorCondition.UNSUPPORTED_VERSION,
text="unsupported version"
)
self._smachine.state = State.OPEN
def _rx_stream_error(self, err):
self._fail(err.to_exception())
def _rx_stream_footer(self):
if self._smachine.state < State.CLOSING:
# any other state, this is an issue
if self._exception is None:
self._fail(ConnectionError("stream closed by peer"))
self.close()
elif self._smachine.state >= State.CLOSING_STREAM_FOOTER_RECEIVED:
self._logger.info("late stream footer received")
return
self._close_transport()
self._smachine.state = State.CLOSING_STREAM_FOOTER_RECEIVED
def _rx_stream_features(self, features):
for fut in self._features_futures:
if fut.done():
continue
fut.set_result(features)
try:
self._error_futures.remove(fut)
except ValueError:
pass
self._features_futures.clear()
def _rx_feed(self, blob):
try:
self._parser.feed(blob)
except sax.SAXParseException as exc:
if (exc.getException().args[0].startswith(
pyexpat.errors.XML_ERROR_UNDEFINED_ENTITY)):
# this will raise an appropriate stream error
xml.XMPPLexicalHandler.startEntity("foo")
raise errors.StreamError(
condition=errors.StreamErrorCondition.BAD_FORMAT,
text=str(exc)
)
except errors.StreamError:
raise
except Exception:
self._logger.exception(
"unexpected exception while parsing stanza"
" bubbled up through parser. stream so dead.")
raise errors.StreamError(
condition=errors.StreamErrorCondition.INTERNAL_SERVER_ERROR,
text="Internal error while parsing XML. Client logs have more"
" details."
)
def _deadtime_hard_limit_triggered(self):
self._logger.debug("dead time hard limit exceeded")
# pretend full shut-down handshake has happened
if self._smachine.state != State.CLOSED:
self._smachine.state = State.CLOSING_STREAM_FOOTER_RECEIVED
self._transport_closing = True
if self._transport is not None:
self._transport.abort()
self._exception = self._exception or ConnectionError(
"connection timeout (dead time hard limit exceeded)"
)
def connection_made(self, transport):
if self._smachine.state != State.READY:
raise self._invalid_state("connection_made")
assert self._transport is None
self._transport = transport
self._writer = None
self._exception = None
# we need to set the state before we call reset()
self._smachine.state = State.STREAM_HEADER_SENT
self.reset()
def connection_lost(self, exc):
# in connection_lost, we really cannot do anything except shutting down
# the stream without sending any more data
if self._smachine.state == State.CLOSED:
return
self._smachine.state = State.CLOSED
self._exception = self._exception or exc
self._kill_state()
self._writer = None
self._transport = None
self._monitor.deadtime_hard_limit = None
self._monitor.deadtime_soft_limit = None
self._closing_future.cancel()
def data_received(self, blob):
self._logger.debug("RECV %r", blob)
self._monitor.notify_received()
try:
self._rx_feed(blob)
except errors.StreamError as exc:
stanza_obj = nonza.StreamError.from_exception(exc)
if not self._writer.closed:
self._writer.send(stanza_obj)
self._fail(exc)
# shutdown, we do not really care about by the
# server at this point
self._close_transport()
def eof_received(self):
if self._smachine.state == State.OPEN:
# close and set to EOF received
self.close()
# common actions below
elif (self._smachine.state == State.CLOSING or
self._smachine.state == State.CLOSING_STREAM_FOOTER_RECEIVED):
# these states are fine, common actions below
pass
else:
self._logger.warning("unexpected eof_received (in %s state)",
self._smachine.state)
# common actions below
self._smachine.state = State.CLOSING_STREAM_FOOTER_RECEIVED
self._close_transport()
def close(self):
"""
Close the XML stream and the underlying transport.
This gracefully shuts down the XML stream and the transport, if
possible by writing the eof using :meth:`asyncio.Transport.write_eof`
after sending the stream footer.
After a call to :meth:`close`, no other stream manipulating or sending
method can be called; doing so will result in a
:class:`ConnectionError` exception or any exception caused by the
transport during shutdown.
Calling :meth:`close` while the stream is closing or closed is a
no-op.
"""
if (self._smachine.state == State.CLOSING or
self._smachine.state == State.CLOSED):
return
self._writer.close()
if self._transport.can_write_eof():
self._transport.write_eof()
if self._smachine.state == State.STREAM_HEADER_SENT:
# at this point, we cannot wait for the peer to send
#
self._close_transport()
self._smachine.state = State.CLOSING
async def close_and_wait(self):
"""
Close the XML stream and the underlying transport and wait for for the
XML stream to be properly terminated.
The underlying transport may still be open when this coroutine returns,
but closing has already been initiated.
The other remarks about :meth:`close` hold.
"""
self.close()
await self._smachine.wait_for_at_least(
State.CLOSING_STREAM_FOOTER_RECEIVED
)
def _kill_state(self):
if self._writer:
self._writer.abort()
self._processor = None
self._parser = None
def _reset_state(self):
self._kill_state()
self._processor = xml.XMPPXMLProcessor()
self._processor.stanza_parser = self.stanza_parser
self._processor.on_stream_header = self._rx_stream_header
self._processor.on_stream_footer = self._rx_stream_footer
self._processor.on_exception = self._rx_exception
self._parser = xml.make_parser()
self._parser.setContentHandler(self._processor)
self._debug_wrapper = None
if self._logger.getEffectiveLevel() <= logging.DEBUG:
dest = DebugWrapper(self._transport, self._logger)
self._debug_wrapper = dest
else:
dest = self._transport
self._writer = xml.XMLStreamWriter(
dest,
self._to,
nsmap={None: self._default_namespace},
from_=self._from,
sorted_attributes=self._sorted_attributes)
def reset(self):
"""
Reset the stream by discarding all state and re-sending the stream
header.
Calling :meth:`reset` when the stream is disconnected or currently
disconnecting results in either :class:`ConnectionError` being raised
or the exception which caused the stream to die (possibly a received
stream error or a transport error) to be reraised.
:meth:`reset` puts the stream into :attr:`~State.STREAM_HEADER_SENT`
state and it cannot be used for sending XSOs until the peer stream
header has been received. Usually, this is not a problem as stream
resets only occur during stream negotiation and stream negotiation
typically waits for the peers feature node to arrive first.
"""
self._require_connection(accept_partial=True)
self._reset_state()
self._writer.start()
self._smachine.rewind(State.STREAM_HEADER_SENT)
def abort(self):
"""
Abort the stream by writing an EOF if possible and closing the
transport.
The transport is closed using :meth:`asyncio.BaseTransport.close`, so
buffered data is sent, but no more data will be received. The stream is
in :attr:`State.CLOSED` state afterwards.
This also works if the stream is currently closing, that is, waiting
for the peer to send a stream footer. In that case, the stream will be
closed locally as if the stream footer had been received.
.. versionadded:: 0.5
"""
if self._smachine.state == State.CLOSED:
return
if self._smachine.state == State.READY:
self._smachine.state = State.CLOSED
return
if (self._smachine.state != State.CLOSING and
self._transport.can_write_eof()):
self._transport.write_eof()
self._close_transport()
def send_xso(self, obj):
"""
Send an XSO over the stream.
:param obj: The object to send.
:type obj: :class:`~.XSO`
:raises ConnectionError: if the connection is not fully established
yet.
:raises aioxmpp.errors.StreamError: if a stream error was received or
sent.
:raises OSError: if the stream got disconnected due to a another
permanent transport error
:raises Exception: if serialisation of `obj` failed
Calling :meth:`send_xso` while the stream is disconnected,
disconnecting or still waiting for the remote to send a stream header
causes :class:`ConnectionError` to be raised. If the stream got
disconnected due to a transport or stream error, that exception is
re-raised instead of the :class:`ConnectionError`.
.. versionchanged:: 0.9
Exceptions occurring during serialisation of `obj` are re-raised and
*no* content is sent over the stream. The stream is still valid and
usable afterwards.
"""
self._require_connection()
self._writer.send(obj)
def can_starttls(self):
"""
Return true if the transport supports STARTTLS and false otherwise.
If the stream is currently not connected, this returns false.
"""
return (hasattr(self._transport, "can_starttls") and
self._transport.can_starttls())
async def starttls(self, ssl_context, post_handshake_callback=None):
"""
Start TLS on the transport and wait for it to complete.
The `ssl_context` and `post_handshake_callback` arguments are forwarded
to the transports
:meth:`aioopenssl.STARTTLSTransport.starttls` coroutine method.
If the transport does not support starttls, :class:`RuntimeError` is
raised; support for starttls can be discovered by querying
:meth:`can_starttls`.
After :meth:`starttls` returns, you must call :meth:`reset`. Any other
method may fail in interesting ways as the internal state is discarded
when starttls succeeds, for security reasons. :meth:`reset` re-creates
the internal structures.
"""
self._require_connection()
if not self.can_starttls():
raise RuntimeError("starttls not available on transport")
await self._transport.starttls(ssl_context, post_handshake_callback)
self._reset_state()
def error_future(self):
"""
Return a future which will receive the next XML stream error as
exception.
It is safe to cancel the future at any time.
"""
fut = asyncio.Future(loop=self._loop)
self._error_futures.append(fut)
return fut
def features_future(self):
"""
Return a future which will receive the next XML stream features (as
return value) or the next XML stream error (as exception), whichever
happens first.
It is safe to cancel this future at any time.
"""
fut = self.error_future()
self._features_futures.append(fut)
return fut
@property
def transport(self):
"""
The underlying :class:`asyncio.Transport` instance. This attribute is
:data:`None` if the :class:`XMLStream` is currently not connected.
This attribute cannot be set.
"""
return self._transport
@property
def state(self):
"""
The current :class:`State` of the XML stream.
This attribute cannot be set.
"""
return self._smachine.state
@contextlib.contextmanager
def mute(self):
"""
A context-manager which prohibits logging of data sent over the stream.
Data sent over the stream is replaced with
````. This is mainly useful during
authentication.
"""
if self._debug_wrapper is None:
yield
else:
with self._debug_wrapper.mute():
yield
@property
def deadtime_soft_limit(self):
"""
This is part of the timeout handling of :class:`XMLStream` objects. The
timeout handling works like this:
* There exist two timers, *soft* and *hard* limit.
* Reception of *any* data resets both timers.
* When the *soft* limit timer is triggered, the
:meth:`on_deadtime_soft_limit_tripped` signal is emitted. Nothing
else happens. The user is expected to do something which would cause
the server to send data to prevent the *hard* limit from tripping.
* When the *hard* limit timer is triggered, the stream is considered
dead and it is aborted and closed with an appropriate
:class:`ConnectionError`.
This attribute controls the timeout for the *soft* limit timer, as
:class:`datetime.timedelta`. The default is :data:`None`, which
disables the timer altogether.
.. versionadded:: 0.10
"""
return self._monitor.deadtime_soft_limit
@deadtime_soft_limit.setter
def deadtime_soft_limit(self, value):
self._monitor.deadtime_soft_limit = value
@property
def deadtime_hard_limit(self):
"""
This is part of the timeout handling of :class:`XMLStream` objects.
See :attr:`deadtime_soft_limit` for details.
This attribute controls the timeout for the *hard* limit timer, as
:class:`datetime.timedelta`. The default is :data:`None`, which
disables the timer altogether.
Setting the *hard* limit timer to :data:`None` means that the
:class:`XMLStream` will never timeout by itself.
.. versionadded:: 0.10
"""
return self._monitor.deadtime_hard_limit
@deadtime_hard_limit.setter
def deadtime_hard_limit(self, value):
self._monitor.deadtime_hard_limit = value
async def send_and_wait_for(xmlstream, send, wait_for,
timeout=None,
cb=None):
fut = asyncio.Future()
wait_for = list(wait_for)
def receive(obj):
nonlocal fut, stack
if cb is not None:
cb(obj)
fut.set_result(obj)
stack.close()
failure_future = xmlstream.error_future()
with contextlib.ExitStack() as stack:
for anticipated_cls in wait_for:
xmlstream.stanza_parser.add_class(
anticipated_cls,
receive)
stack.callback(
xmlstream.stanza_parser.remove_class,
anticipated_cls,
)
for to_send in send:
xmlstream.send_xso(to_send)
done, pending = await asyncio.wait(
[
fut,
failure_future,
],
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
)
for other_fut in pending:
other_fut.cancel()
if fut in done:
return fut.result()
if failure_future in done:
failure_future.result()
else:
failure_future.cancel()
raise TimeoutError()
async def reset_stream_and_get_features(xmlstream, timeout=None):
xmlstream.reset()
fut = xmlstream.features_future()
if timeout is not None:
try:
result = await asyncio.wait_for(fut, timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError from None
else:
result = await xmlstream.features_future()
return result
def send_stream_error_and_close(
xmlstream,
condition,
text,
custom_condition=None):
xmlstream.send_xso(nonza.StreamError(
condition=condition,
text=text))
if custom_condition is not None:
logger.warning(
"custom_condition argument to send_stream_error_and_close"
" not implemented",
)
xmlstream.close()
aioxmpp/pubsub/ 0000775 0000000 0000000 00000000000 14160146213 0014031 5 ustar 00root root 0000000 0000000 aioxmpp/pubsub/__init__.py 0000664 0000000 0000000 00000010063 14160146213 0016142 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.pubsub` --- Publish-Subscribe support (:xep:`0060`)
##################################################################
This subpackage provides client-side support for :xep:`0060` publish-subscribe
services.
.. versionadded:: 0.6
Using Publish-Subscribe
=======================
To start using PubSub services in your application, you have to load the
:class:`.PubSubClient` into the client, using :meth:`~.node.Client.summon`.
.. currentmodule:: aioxmpp
.. autoclass:: PubSubClient
.. currentmodule:: aioxmpp.pubsub
.. class:: Service
Alias of :class:`.PubSubClient`.
.. deprecated:: 0.8
The alias will be removed in 1.0.
.. currentmodule:: aioxmpp.pubsub.xso
XSOs
====
Registering payloads
--------------------
PubSub payloads are must be registered at several places, so there is a
short-hand function to handle that:
.. autofunction:: as_payload_class
Features
--------
.. autoclass:: Feature
Generic namespace
-----------------
The top-level XSO is :class:`Request`. Below that, several different XSOs are
allowed, which are listed below the documentation of :class:`Request` in
alphabetical order.
.. autoclass:: Request
.. autoclass:: Affiliation
.. autoclass:: Affiliations
.. autoclass:: Configure
.. autoclass:: Create
.. autoclass:: Default
.. autoclass:: Item
.. autoclass:: Items
.. autoclass:: Options
.. autoclass:: Publish
.. autoclass:: Retract
.. autoclass:: Subscribe
.. autoclass:: SubscribeOptions
.. autoclass:: Subscription
.. autoclass:: Subscriptions
.. autoclass:: Unsubscribe
Owner namespace
---------------
The top-level XSO is :class:`OwnerRequest`. Below that, several different XSOs
are allowed, which are listed below the documentation of :class:`OwnerRequest`
in alphabetical order.
.. autoclass:: OwnerRequest
.. autoclass:: OwnerAffiliation
.. autoclass:: OwnerAffiliations
.. autoclass:: OwnerConfigure
.. autoclass:: OwnerDefault
.. autoclass:: OwnerDelete
.. autoclass:: OwnerPurge
.. autoclass:: OwnerRedirect
.. autoclass:: OwnerSubscription
.. autoclass:: OwnerSubscriptions
Application-condition error XSOs
--------------------------------
Application-condition XSOs for use in
:attr:`.stanza.Error.application_condition` are also defined for the error
conditions specified by :xep:`0060`. They are listed in alphabetical order
below:
.. autoclass:: ClosedNode()
.. autoclass:: ConfigurationRequired()
.. autoclass:: InvalidJID()
.. autoclass:: InvalidOptions()
.. autoclass:: InvalidPayload()
.. autoclass:: InvalidSubID()
.. autoclass:: ItemForbidden()
.. autoclass:: ItemRequired()
.. autoclass:: JIDRequired()
.. autoclass:: MaxItemsExceeded()
.. autoclass:: MaxNodesExceeded()
.. autoclass:: NodeIDRequired()
.. autoclass:: NotInRosterGroup()
.. autoclass:: NotSubscribed()
.. autoclass:: PayloadTooBig()
.. autoclass:: PayloadRequired()
.. autoclass:: PendingSubscription()
.. autoclass:: PresenceSubscriptionRequired()
.. autoclass:: SubIDRequired()
.. autoclass:: TooManySubscriptions()
.. autoclass:: Unsupported()
Forms
-----
.. currentmodule:: aioxmpp.pubsub
.. autoclass:: NodeConfigForm
"""
from .service import PubSubClient # NOQA: F401
from .xso import NodeConfigForm # NOQA: F401
Service = PubSubClient
aioxmpp/pubsub/service.py 0000664 0000000 0000000 00000106636 14160146213 0016057 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import aioxmpp.callbacks
import aioxmpp.disco
import aioxmpp.service
import aioxmpp.stanza
import aioxmpp.structs
from . import xso as pubsub_xso
class PubSubClient(aioxmpp.service.Service):
"""
Client service implementing a Publish-Subscribe client. By loading it into
a client, it is possible to subscribe to, publish to and otherwise interact
with Publish-Subscribe nodes.
.. note::
Signal handlers attached to any of the signals below **must** accept
arbitrary keyword arguments for forward compatibility. If any of the
arguments is listed as positional in the signal signature, it is always
present and handed as positional argument.
Subscriber use cases:
.. autosummary::
get_default_config
get_items
get_items_by_id
get_subscription_config
get_subscriptions
set_subscription_config
subscribe
unsubscribe
on_affiliation_update
on_item_published
on_item_retracted
on_node_deleted
on_subscription_update
Publisher use cases:
.. autosummary::
notify
publish
retract
Owner use cases:
.. autosummary::
change_node_affiliations
change_node_subscriptions
create
delete
get_nodes
get_node_affiliations
get_node_config
get_node_subscriptions
purge
set_node_config
Meta-information about the service:
.. automethod:: get_features
Subscribing, unsubscribing and listing subscriptions:
.. automethod:: get_subscriptions
.. automethod:: subscribe
.. automethod:: unsubscribe
Configuring subscriptions:
.. automethod:: get_default_config
.. automethod:: get_subscription_config
.. automethod:: set_subscription_config
Retrieving items:
.. automethod:: get_items
.. automethod:: get_items_by_id
Publishing and retracting items:
.. automethod:: notify
.. automethod:: publish
.. automethod:: retract
Manage nodes:
.. automethod:: change_node_affiliations
.. automethod:: change_node_subscriptions
.. automethod:: create
.. automethod:: delete
.. automethod:: get_nodes
.. automethod:: get_node_affiliations
.. automethod:: get_node_config
.. automethod:: get_node_subscriptions
.. automethod:: purge
.. automethod:: set_node_config
Receiving notifications:
.. signal:: on_item_published(jid, node, item, *, message=None)
Fires when a new item is published to a node to which we have a
subscription.
The node at which the item has been published is identified by `jid`
and `node`. `item` is the :class:`xso.EventItem` payload.
`message` is the :class:`.Message` which carried the notification.
If a notification message contains more than one published item, the
event is fired for each of the items, and `message` is passed to all
of them.
.. signal:: on_item_retracted(jid, node, id_, *, message=None)
Fires when an item is retracted from a node to which we have a
subscription.
The node at which the item has been retracted is identified by `jid`
and `node`. `id_` is the ID of the item which has been retract.
`message` is the :class:`.Message` which carried the notification.
If a notification message contains more than one retracted item, the
event is fired for each of the items, and `message` is passed to all
of them.
.. signal:: on_node_deleted(jid, node, *, redirect_uri=None, message=None)
Fires when a node is deleted. `jid` and `node` identify the node.
If the notification included a redirection URI, it is passed as
`redirect_uri`. Otherwise, :data:`None` is passed for `redirect_uri`.
`message` is the :class:`.Message` which carried the notification.
.. signal:: on_affiliation_update(jid, node, affiliation, *, message=None)
Fires when the affiliation with a node is updated.
`jid` and `node` identify the node for which the affiliation was updated.
`affiliation` is the new affiliaton.
`message` is the :class:`.Message` which carried the notification.
.. signal:: on_subscription_update(jid, node, state, *, subid=None, message=None)
Fires when the subscription state is updated.
`jid` and `node` identify the node for which the subscription was updated.
`subid` is optional and if it is not :data:`None` it is the affected
subscription id. `state` is the new subscription state.
This event can happen in several cases, for example when a subscription
request is approved by the node owner or when a subscription is cancelled.
`message` is the :class:`.Message` which carried the notification.
.. versionchanged:: 0.8
This class was formerly known as :class:`aioxmpp.pubsub.Service`. It
is still available under that name, but the alias will be removed in
1.0.
""" # NOQA: E501
ORDER_AFTER = [
aioxmpp.DiscoClient,
]
on_item_published = aioxmpp.callbacks.Signal()
on_item_retracted = aioxmpp.callbacks.Signal()
on_node_deleted = aioxmpp.callbacks.Signal()
on_affiliation_update = aioxmpp.callbacks.Signal()
on_subscription_update = aioxmpp.callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._disco = self.dependencies[aioxmpp.DiscoClient]
@aioxmpp.service.inbound_message_filter
def filter_inbound_message(self, msg):
if (msg.xep0060_event is not None and
msg.xep0060_event.payload is not None):
payload = msg.xep0060_event.payload
if isinstance(payload, pubsub_xso.EventItems):
for item in payload.items:
node = item.node or payload.node
self.on_item_published(
msg.from_,
node,
item,
message=msg,
)
for retract in payload.retracts:
node = payload.node
self.on_item_retracted(
msg.from_,
node,
retract.id_,
message=msg,
)
elif isinstance(payload, pubsub_xso.EventDelete):
self.on_node_deleted(
msg.from_,
payload.node,
redirect_uri=payload.redirect_uri,
message=msg,
)
elif (msg.xep0060_request is not None and
msg.xep0060_request.payload is not None):
payload = msg.xep0060_request.payload
if isinstance(payload, pubsub_xso.Affiliations):
for item in payload.affiliations:
self.on_affiliation_update(
msg.from_,
item.node,
item.affiliation,
message=msg,
)
elif isinstance(payload, pubsub_xso.Subscriptions):
for item in payload.subscriptions:
self.on_subscription_update(
msg.from_,
item.node,
item.subscription,
subid=item.subid,
message=msg,
)
else:
return msg
async def get_features(self, jid):
"""
Return the features supported by a service.
:param jid: Address of the PubSub service to query.
:type jid: :class:`aioxmpp.JID`
:return: Set of supported features
:rtype: set containing :class:`~.pubsub.xso.Feature` enumeration
members.
This simply uses service discovery to obtain the set of features and
converts the features to :class:`~.pubsub.xso.Feature` enumeration
members. To get the full feature information, resort to using
:meth:`.DiscoClient.query_info` directly on `jid`.
Features returned by the peer which are not valid pubsub features are
not returned.
"""
response = await self._disco.query_info(jid)
result = set()
for feature in response.features:
try:
result.add(pubsub_xso.Feature(feature))
except ValueError:
continue
return result
async def subscribe(self, jid, node=None, *,
subscription_jid=None,
config=None):
"""
Subscribe to a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to subscribe to.
:type node: :class:`str`
:param subscription_jid: The address to subscribe to the service.
:type subscription_jid: :class:`aioxmpp.JID`
:param config: Optional configuration of the subscription
:type config: :class:`~.forms.Data`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The response from the server.
:rtype: :class:`.xso.Request`
By default, the subscription request will be for the bare JID of the
client. It can be specified explicitly using the `subscription_jid`
argument.
If the service requires it or if it makes sense for other reasons, the
subscription configuration :class:`~.forms.Data` form can be passed
using the `config` argument.
On success, the whole :class:`.xso.Request` object returned by the
server is returned. It contains a :class:`.xso.Subscription`
:attr:`~.xso.Request.payload` which has information on the nature of
the subscription (it may be ``"pending"`` or ``"unconfigured"``) and
the :attr:`~.xso.Subscription.subid` which may be required for other
operations.
On failure, the corresponding :class:`~.errors.XMPPError` is raised.
"""
subscription_jid = subscription_jid or self.client.local_jid.bare()
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.SET)
iq.payload = pubsub_xso.Request(
pubsub_xso.Subscribe(subscription_jid, node=node)
)
if config is not None:
iq.payload.options = pubsub_xso.Options(
subscription_jid,
node=node
)
iq.payload.options.data = config
response = await self.client.send(iq)
return response
async def unsubscribe(self, jid, node=None, *,
subscription_jid=None,
subid=None):
"""
Unsubscribe from a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to unsubscribe from.
:type node: :class:`str`
:param subscription_jid: The address to subscribe from the service.
:type subscription_jid: :class:`aioxmpp.JID`
:param subid: Unique ID of the subscription to remove.
:type subid: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
By default, the unsubscribe request will be for the bare JID of the
client. It can be specified explicitly using the `subscription_jid`
argument.
If available, the `subid` should also be specified.
If an error occurs, the corresponding :class:`~.errors.XMPPError` is
raised.
"""
subscription_jid = subscription_jid or self.client.local_jid.bare()
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.SET)
iq.payload = pubsub_xso.Request(
pubsub_xso.Unsubscribe(subscription_jid, node=node, subid=subid)
)
await self.client.send(iq)
async def get_subscription_config(self, jid, node=None, *,
subscription_jid=None,
subid=None):
"""
Request the current configuration of a subscription.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to query.
:type node: :class:`str`
:param subscription_jid: The address to query the configuration for.
:type subscription_jid: :class:`aioxmpp.JID`
:param subid: Unique ID of the subscription to query.
:type subid: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The current configuration of the subscription.
:rtype: :class:`~.forms.Data`
By default, the request will be on behalf of the bare JID of the
client. It can be overridden using the `subscription_jid` argument.
If available, the `subid` should also be specified.
On success, the :class:`~.forms.Data` form is returned.
If an error occurs, the corresponding :class:`~.errors.XMPPError` is
raised.
"""
subscription_jid = subscription_jid or self.client.local_jid.bare()
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.GET)
iq.payload = pubsub_xso.Request()
iq.payload.options = pubsub_xso.Options(
subscription_jid,
node=node,
subid=subid,
)
response = await self.client.send(iq)
return response.options.data
async def set_subscription_config(self, jid, data, node=None, *,
subscription_jid=None,
subid=None):
"""
Update the configuration of a subscription.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param data: The new configuration of the subscription.
:type data: :class:`~.forms.Data`
:param node: Name of the PubSub node to modify.
:type node: :class:`str`
:param subscription_jid: The address to modify the configuration for.
:type subscription_jid: :class:`aioxmpp.JID`
:param subid: Unique ID of the subscription to modify.
:type subid: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
By default, the request will be on behalf of the bare JID of the
client. It can be overridden using the `subscription_jid` argument.
If available, the `subid` should also be specified.
The configuration must be given as `data` as a
:class:`~.forms.Data` instance.
If an error occurs, the corresponding :class:`~.errors.XMPPError` is
raised.
"""
subscription_jid = subscription_jid or self.client.local_jid.bare()
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.SET)
iq.payload = pubsub_xso.Request()
iq.payload.options = pubsub_xso.Options(
subscription_jid,
node=node,
subid=subid,
)
iq.payload.options.data = data
await self.client.send(iq)
async def get_default_config(self, jid, node=None):
"""
Request the default configuration of a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to query.
:type node: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The default configuration of subscriptions at the node.
:rtype: :class:`~.forms.Data`
On success, the :class:`~.forms.Data` form is returned.
If an error occurs, the corresponding :class:`~.errors.XMPPError` is
raised.
"""
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.GET)
iq.payload = pubsub_xso.Request(
pubsub_xso.Default(node=node)
)
response = await self.client.send(iq)
return response.payload.data
async def get_node_config(self, jid, node=None):
"""
Request the configuration of a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to query.
:type node: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The configuration of the node.
:rtype: :class:`~.forms.Data`
On success, the :class:`~.forms.Data` form is returned.
If an error occurs, the corresponding :class:`~.errors.XMPPError` is
raised.
"""
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.GET)
iq.payload = pubsub_xso.OwnerRequest(
pubsub_xso.OwnerConfigure(node=node)
)
response = await self.client.send(iq)
return response.payload.data
async def set_node_config(self, jid, config, node=None):
"""
Update the configuration of a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param config: Configuration form
:type config: :class:`aioxmpp.forms.Data`
:param node: Name of the PubSub node to query.
:type node: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The configuration of the node.
:rtype: :class:`~.forms.Data`
.. seealso::
:class:`aioxmpp.pubsub.NodeConfigForm`
"""
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.SET)
iq.payload = pubsub_xso.OwnerRequest(
pubsub_xso.OwnerConfigure(node=node)
)
iq.payload.payload.data = config
await self.client.send(iq)
async def get_items(self, jid, node, *, max_items=None):
"""
Request the most recent items from a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to query.
:type node: :class:`str`
:param max_items: Number of items to return at most.
:type max_items: :class:`int` or :data:`None`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The response from the server.
:rtype: :class:`.xso.Request`.
By default, as many as possible items are requested. If `max_items` is
given, it must be a positive integer specifying the maximum number of
items which is to be returned by the server.
Return the :class:`.xso.Request` object, which has a
:class:`~.xso.Items` :attr:`~.xso.Request.payload`.
"""
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.GET)
iq.payload = pubsub_xso.Request(
pubsub_xso.Items(node, max_items=max_items)
)
return await self.client.send(iq)
async def get_items_by_id(self, jid, node, ids):
"""
Request specific items by their IDs from a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to query.
:type node: :class:`str`
:param ids: The item IDs to return.
:type ids: :class:`~collections.abc.Iterable` of :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The response from the service
:rtype: :class:`.xso.Request`
`ids` must be an iterable of :class:`str` of the IDs of the items to
request from the pubsub node. If the iterable is empty,
:class:`ValueError` is raised (as otherwise, the request would be
identical to calling :meth:`get_items` without `max_items`).
Return the :class:`.xso.Request` object, which has a
:class:`~.xso.Items` :attr:`~.xso.Request.payload`.
"""
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.GET)
iq.payload = pubsub_xso.Request(
pubsub_xso.Items(node)
)
iq.payload.payload.items = [
pubsub_xso.Item(id_)
for id_ in ids
]
if not iq.payload.payload.items:
raise ValueError("ids must not be empty")
return await self.client.send(iq)
async def get_subscriptions(self, jid, node=None):
"""
Return all subscriptions of the local entity to a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to query.
:type node: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The subscriptions response from the service.
:rtype: :class:`.xso.Subscriptions`
If `node` is :data:`None`, subscriptions on all nodes of the entity
`jid` are listed.
"""
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.GET)
iq.payload = pubsub_xso.Request(
pubsub_xso.Subscriptions(node=node)
)
response = await self.client.send(iq)
return response.payload
async def publish(self, jid, node, payload, *,
id_=None,
publish_options=None):
"""
Publish an item to a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to publish to.
:type node: :class:`str`
:param payload: Registered payload to publish.
:type payload: :class:`aioxmpp.xso.XSO`
:param id_: Item ID to use for the item.
:type id_: :class:`str` or :data:`None`.
:param publish_options: A data form with the options for the publish
request
:type publish_options: :class:`aioxmpp.forms.Data`
:raises aioxmpp.errors.XMPPError: as returned by the service
:raises RuntimeError: if `publish_options` is not :data:`None` but
the service does not support `publish_options`
:return: The Item ID which was used to publish the item.
:rtype: :class:`str` or :data:`None`
Publish the given `payload` (which must be a :class:`aioxmpp.xso.XSO`
registered with :attr:`.xso.Item.registered_payload`).
The item is published to `node` at `jid`. If `id_` is given, it is used
as the ID for the item. If an item with the same ID already exists at
the node, it is replaced. If no ID is given, a ID is generated by the
server.
If `publish_options` is given, it is passed as ````
element to the server. This needs to be a data form which allows to
define e.g. node configuration as a pre-condition to publishing. If
the publish-options cannot be satisfied, the server will raise a
:attr:`aioxmpp.ErrorCondition.CONFLICT` error.
If `publish_options` is given and the server does not announce the
:attr:`aioxmpp.pubsub.xso.Feature.PUBLISH_OPTIONS` feature,
:class:`RuntimeError` is raised to prevent security issues (e.g. if
the publish options attempt to assert a restrictive access model).
Return the ID of the item as published (or :data:`None` if the server
does not inform us; this is unfortunately common).
"""
publish = pubsub_xso.Publish()
publish.node = node
if payload is not None:
item = pubsub_xso.Item()
item.id_ = id_
item.registered_payload = payload
publish.item = item
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.SET)
iq.payload = pubsub_xso.Request(
publish
)
if publish_options is not None:
features = await self.get_features(jid)
if pubsub_xso.Feature.PUBLISH_OPTIONS not in features:
raise RuntimeError(
"publish-options given, but not supported by server"
)
iq.payload.publish_options = pubsub_xso.PublishOptions()
iq.payload.publish_options.data = publish_options
response = await self.client.send(iq)
if response is not None and response.payload.item is not None:
return response.payload.item.id_ or id_
return id_
async def notify(self, jid, node):
"""
Notify all subscribers of a node without publishing an item.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to send a notify from.
:type node: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
"Publish" to the `node` at `jid` without any item. This merely fans out
a notification. The exact semantics can be checked in :xep:`60`.
"""
await self.publish(jid, node, None)
async def retract(self, jid, node, id_, *, notify=False):
"""
Retract a previously published item from a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to send a notify from.
:type node: :class:`str`
:param id_: The ID of the item to retract.
:type id_: :class:`str`
:param notify: Flag indicating whether subscribers shall be notified
about the retraction.
:type notify: :class:`bool`
:raises aioxmpp.errors.XMPPError: as returned by the service
Retract an item previously published to `node` at `jid`. `id_` must be
the ItemID of the item to retract.
If `notify` is set to true, notifications will be generated (by setting
the `notify` attribute on the retraction request).
"""
retract = pubsub_xso.Retract()
retract.node = node
item = pubsub_xso.Item()
item.id_ = id_
retract.item = item
retract.notify = notify
iq = aioxmpp.stanza.IQ(to=jid, type_=aioxmpp.structs.IQType.SET)
iq.payload = pubsub_xso.Request(
retract
)
await self.client.send(iq)
async def create(self, jid, node=None):
"""
Create a new node at a service.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to create.
:type node: :class:`str` or :data:`None`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The name of the created node.
:rtype: :class:`str`
If `node` is :data:`None`, an instant node is created (see :xep:`60`).
The server may not support or allow the creation of instant nodes.
Return the actual `node` identifier.
"""
create = pubsub_xso.Create()
create.node = node
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=jid,
payload=pubsub_xso.Request(create)
)
response = await self.client.send(iq)
if response is not None and response.payload.node is not None:
return response.payload.node
return node
async def delete(self, jid, node, *, redirect_uri=None):
"""
Delete an existing node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the PubSub node to delete.
:type node: :class:`str` or :data:`None`
:param redirect_uri: A URI to send to subscribers to indicate a
replacement for the deleted node.
:type redirect_uri: :class:`str` or :data:`None`
:raises aioxmpp.errors.XMPPError: as returned by the service
Optionally, a `redirect_uri` can be given. The `redirect_uri` will be
sent to subscribers in the message notifying them about the node
deletion.
"""
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=jid,
payload=pubsub_xso.OwnerRequest(
pubsub_xso.OwnerDelete(
node,
redirect_uri=redirect_uri
)
)
)
await self.client.send(iq)
async def get_nodes(self, jid, node=None):
"""
Request all nodes at a service or collection node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the collection node to query
:type node: :class:`str` or :data:`None`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The list of nodes at the service or collection node.
:rtype: :class:`~collections.abc.Sequence` of tuples consisting of the
node name and its description.
Request the nodes available at `jid`. If `node` is not :data:`None`,
the request returns the children of the :xep:`248` collection node
`node`. Make sure to check for the appropriate server feature first.
Return a list of tuples consisting of the node names and their
description (if available, otherwise :data:`None`). If more information
is needed, use :meth:`.DiscoClient.get_items` directly.
Only nodes whose :attr:`~.disco.xso.Item.jid` match the `jid` are
returned.
"""
response = await self._disco.query_items(
jid,
node=node,
)
result = []
for item in response.items:
if item.jid != jid:
continue
result.append((
item.node,
item.name,
))
return result
async def get_node_affiliations(self, jid, node):
"""
Return the affiliations of other jids at a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the node to query
:type node: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The response from the service.
:rtype: :class:`.xso.OwnerRequest`
The affiliations are returned as :class:`.xso.OwnerRequest` instance
whose :attr:`~.xso.OwnerRequest.payload` is a
:class:`.xso.OwnerAffiliations` instance.
"""
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.GET,
to=jid,
payload=pubsub_xso.OwnerRequest(
pubsub_xso.OwnerAffiliations(node),
)
)
return await self.client.send(iq)
async def get_node_subscriptions(self, jid, node):
"""
Return the subscriptions of other jids with a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the node to query
:type node: :class:`str`
:raises aioxmpp.errors.XMPPError: as returned by the service
:return: The response from the service.
:rtype: :class:`.xso.OwnerRequest`
The subscriptions are returned as :class:`.xso.OwnerRequest` instance
whose :attr:`~.xso.OwnerRequest.payload` is a
:class:`.xso.OwnerSubscriptions` instance.
"""
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.GET,
to=jid,
payload=pubsub_xso.OwnerRequest(
pubsub_xso.OwnerSubscriptions(node),
)
)
return await self.client.send(iq)
async def change_node_affiliations(self, jid, node, affiliations_to_set):
"""
Update the affiliations at a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the node to modify
:type node: :class:`str`
:param affiliations_to_set: The affiliations to set at the node.
:type affiliations_to_set: :class:`~collections.abc.Iterable` of tuples
consisting of the JID to affiliate and the affiliation to use.
:raises aioxmpp.errors.XMPPError: as returned by the service
`affiliations_to_set` must be an iterable of pairs (`jid`,
`affiliation`), where the `jid` indicates the JID for which the
`affiliation` is to be set.
"""
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=jid,
payload=pubsub_xso.OwnerRequest(
pubsub_xso.OwnerAffiliations(
node,
affiliations=[
pubsub_xso.OwnerAffiliation(
jid,
affiliation
)
for jid, affiliation in affiliations_to_set
]
)
)
)
await self.client.send(iq)
async def change_node_subscriptions(self, jid, node, subscriptions_to_set):
"""
Update the subscriptions at a node.
:param jid: Address of the PubSub service.
:type jid: :class:`aioxmpp.JID`
:param node: Name of the node to modify
:type node: :class:`str`
:param subscriptions_to_set: The subscriptions to set at the node.
:type subscriptions_to_set: :class:`~collections.abc.Iterable` of
tuples consisting of the JID to (un)subscribe and the subscription
level to use.
:raises aioxmpp.errors.XMPPError: as returned by the service
`subscriptions_to_set` must be an iterable of pairs (`jid`,
`subscription`), where the `jid` indicates the JID for which the
`subscription` is to be set.
"""
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=jid,
payload=pubsub_xso.OwnerRequest(
pubsub_xso.OwnerSubscriptions(
node,
subscriptions=[
pubsub_xso.OwnerSubscription(
jid,
subscription
)
for jid, subscription in subscriptions_to_set
]
)
)
)
await self.client.send(iq)
async def purge(self, jid, node):
"""
Delete all items from a node.
:param jid: JID of the PubSub service
:param node: Name of the PubSub node
:type node: :class:`str`
Requires :attr:`.xso.Feature.PURGE`.
"""
iq = aioxmpp.stanza.IQ(
type_=aioxmpp.structs.IQType.SET,
to=jid,
payload=pubsub_xso.OwnerRequest(
pubsub_xso.OwnerPurge(
node
)
)
)
await self.client.send(iq)
aioxmpp/pubsub/xso.py 0000664 0000000 0000000 00000113543 14160146213 0015223 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp.forms
import aioxmpp.stanza
import aioxmpp.xso as xso
from enum import Enum
from aioxmpp.utils import namespaces
class Feature(Enum):
ACCESS_AUTHORIZE = \
"http://jabber.org/protocol/pubsub#access-authorize"
ACCESS_OPEN = \
"http://jabber.org/protocol/pubsub#access-open"
ACCESS_PRESENCE = \
"http://jabber.org/protocol/pubsub#access-presence"
ACCESS_ROSTER = \
"http://jabber.org/protocol/pubsub#access-roster"
ACCESS_WHITELIST = \
"http://jabber.org/protocol/pubsub#access-whitelist"
AUTO_CREATE = \
"http://jabber.org/protocol/pubsub#auto-create"
AUTO_SUBSCRIBE = \
"http://jabber.org/protocol/pubsub#auto-subscribe"
COLLECTIONS = \
"http://jabber.org/protocol/pubsub#collections"
CONFIG_NODE = \
"http://jabber.org/protocol/pubsub#config-node"
CREATE_AND_CONFIGURE = \
"http://jabber.org/protocol/pubsub#create-and-configure"
CREATE_NODES = \
"http://jabber.org/protocol/pubsub#create-nodes"
DELETE_ITEMS = \
"http://jabber.org/protocol/pubsub#delete-items"
DELETE_NODES = \
"http://jabber.org/protocol/pubsub#delete-nodes"
FILTERED_NOTIFICATIONS = \
"http://jabber.org/protocol/pubsub#filtered-notifications"
GET_PENDING = \
"http://jabber.org/protocol/pubsub#get-pending"
INSTANT_NODES = \
"http://jabber.org/protocol/pubsub#instant-nodes"
ITEM_IDS = \
"http://jabber.org/protocol/pubsub#item-ids"
LAST_PUBLISHED = \
"http://jabber.org/protocol/pubsub#last-published"
LEASED_SUBSCRIPTION = \
"http://jabber.org/protocol/pubsub#leased-subscription"
MANAGE_SUBSCRIPTIONS = \
"http://jabber.org/protocol/pubsub#manage-subscriptions"
MEMBER_AFFILIATION = \
"http://jabber.org/protocol/pubsub#member-affiliation"
META_DATA = \
"http://jabber.org/protocol/pubsub#meta-data"
MODIFY_AFFILIATIONS = \
"http://jabber.org/protocol/pubsub#modify-affiliations"
MULTI_COLLECTION = \
"http://jabber.org/protocol/pubsub#multi-collection"
MULTI_SUBSCRIBE = \
"http://jabber.org/protocol/pubsub#multi-subscribe"
OUTCAST_AFFILIATION = \
"http://jabber.org/protocol/pubsub#outcast-affiliation"
PERSISTENT_ITEMS = \
"http://jabber.org/protocol/pubsub#persistent-items"
PRESENCE_NOTIFICATIONS = \
"http://jabber.org/protocol/pubsub#presence-notifications"
PRESENCE_SUBSCRIBE = \
"http://jabber.org/protocol/pubsub#presence-subscribe"
PUBLISH = \
"http://jabber.org/protocol/pubsub#publish"
PUBLISH_OPTIONS = \
"http://jabber.org/protocol/pubsub#publish-options"
PUBLISH_ONLY_AFFILIATION = \
"http://jabber.org/protocol/pubsub#publish-only-affiliation"
PUBLISHER_AFFILIATION = \
"http://jabber.org/protocol/pubsub#publisher-affiliation"
PURGE_NODES = \
"http://jabber.org/protocol/pubsub#purge-nodes"
RETRACT_ITEMS = \
"http://jabber.org/protocol/pubsub#retract-items"
RETRIEVE_AFFILIATIONS = \
"http://jabber.org/protocol/pubsub#retrieve-affiliations"
RETRIEVE_DEFAULT = \
"http://jabber.org/protocol/pubsub#retrieve-default"
RETRIEVE_ITEMS = \
"http://jabber.org/protocol/pubsub#retrieve-items"
RETRIEVE_SUBSCRIPTIONS = \
"http://jabber.org/protocol/pubsub#retrieve-subscriptions"
SUBSCRIBE = \
"http://jabber.org/protocol/pubsub#subscribe"
SUBSCRIPTION_OPTIONS = \
"http://jabber.org/protocol/pubsub#subscription-options"
SUBSCRIPTION_NOTIFICATIONS = \
"http://jabber.org/protocol/pubsub#subscription-notifications"
namespaces.xep0060_features = Feature
namespaces.xep0060 = "http://jabber.org/protocol/pubsub"
namespaces.xep0060_errors = "http://jabber.org/protocol/pubsub#errors"
namespaces.xep0060_event = "http://jabber.org/protocol/pubsub#event"
namespaces.xep0060_owner = "http://jabber.org/protocol/pubsub#owner"
class Affiliation(xso.XSO):
TAG = (namespaces.xep0060, "affiliation")
node = xso.Attr(
"node",
default=None
)
affiliation = xso.Attr(
"affiliation",
validator=xso.RestrictToSet({
"member",
"none",
"outcast",
"owner",
"publisher",
"publish-only",
}),
)
def __init__(self, affiliation, node=None):
super().__init__()
self.affiliation = affiliation
self.node = node
class Affiliations(xso.XSO):
TAG = (namespaces.xep0060, "affiliations")
node = xso.Attr(
"node",
default=None
)
affiliations = xso.ChildList(
[Affiliation],
)
def __init__(self, affiliations=[], node=None):
super().__init__()
self.affiliations[:] = affiliations
self.node = node
class Configure(xso.XSO):
TAG = (namespaces.xep0060, "configure")
data = xso.Child([
aioxmpp.forms.Data,
])
class Create(xso.XSO):
TAG = (namespaces.xep0060, "create")
node = xso.Attr(
"node",
default=None
)
class Default(xso.XSO):
TAG = (namespaces.xep0060, "default")
node = xso.Attr(
"node",
default=None
)
type_ = xso.Attr(
"type",
validator=xso.RestrictToSet({
"leaf",
"collection",
}),
default="leaf",
)
data = xso.Child([
aioxmpp.forms.Data,
])
def __init__(self, *, node=None, data=None):
super().__init__()
self.node = node
self.data = data
class Item(xso.XSO):
TAG = (namespaces.xep0060, "item")
id_ = xso.Attr(
"id",
default=None
)
registered_payload = xso.Child([], strict=True)
unregistered_payload = xso.Collector()
def __init__(self, id_=None):
super().__init__()
self.id_ = id_
class Items(xso.XSO):
TAG = (namespaces.xep0060, "items")
max_items = xso.Attr(
(None, "max_items"),
type_=xso.Integer(),
validator=xso.NumericRange(min_=1),
default=None,
)
node = xso.Attr(
"node",
)
subid = xso.Attr(
"subid",
default=None
)
items = xso.ChildList(
[Item]
)
def __init__(self, node, subid=None, max_items=None):
super().__init__()
self.node = node
self.subid = subid
self.max_items = max_items
class Options(xso.XSO):
TAG = (namespaces.xep0060, "options")
jid = xso.Attr(
"jid",
type_=xso.JID()
)
node = xso.Attr(
"node",
default=None
)
subid = xso.Attr(
"subid",
default=None
)
data = xso.Child([
aioxmpp.forms.Data,
])
def __init__(self, jid, node=None, subid=None):
super().__init__()
self.jid = jid
self.node = node
self.subid = subid
class PublishOptions(xso.XSO):
TAG = (namespaces.xep0060, "publish-options")
data = xso.Child([
aioxmpp.forms.Data,
])
class Publish(xso.XSO):
TAG = (namespaces.xep0060, "publish")
node = xso.Attr(
"node",
)
item = xso.Child([
Item
])
class Retract(xso.XSO):
TAG = (namespaces.xep0060, "retract")
node = xso.Attr(
"node",
)
item = xso.Child([
Item
])
notify = xso.Attr(
"notify",
type_=xso.Bool(),
default=False,
)
class Subscribe(xso.XSO):
TAG = (namespaces.xep0060, "subscribe")
jid = xso.Attr(
"jid",
type_=xso.JID()
)
node = xso.Attr(
"node",
default=None
)
def __init__(self, jid, node=None):
super().__init__()
self.jid = jid
self.node = node
class SubscribeOptions(xso.XSO):
TAG = (namespaces.xep0060, "subscribe-options")
required = xso.ChildTag(
[
(namespaces.xep0060, "required"),
],
allow_none=True
)
class Subscription(xso.XSO):
TAG = (namespaces.xep0060, "subscription")
jid = xso.Attr(
"jid",
type_=xso.JID()
)
node = xso.Attr(
"node",
default=None
)
subid = xso.Attr(
"subid",
default=None
)
subscription = xso.Attr(
"subscription",
validator=xso.RestrictToSet({
"none",
"pending",
"subscribed",
"unconfigured",
}),
default=None
)
subscribe_options = xso.Child(
[SubscribeOptions]
)
def __init__(self, jid, node=None, subid=None, *, subscription=None):
super().__init__()
self.jid = jid
self.node = node
self.subid = subid
self.subscription = subscription
class Subscriptions(xso.XSO):
TAG = (namespaces.xep0060, "subscriptions")
node = xso.Attr(
"node",
default=None
)
subscriptions = xso.ChildList(
[Subscription],
)
def __init__(self, *, subscriptions=[], node=None):
super().__init__()
self.node = node
self.subscriptions[:] = subscriptions
class Unsubscribe(xso.XSO):
TAG = (namespaces.xep0060, "unsubscribe")
jid = xso.Attr(
"jid",
type_=xso.JID()
)
node = xso.Attr(
"node",
default=None
)
subid = xso.Attr(
"subid",
default=None
)
def __init__(self, jid, node=None, subid=None):
super().__init__()
self.jid = jid
self.node = node
self.subid = subid
@aioxmpp.stanza.IQ.as_payload_class
class Request(xso.XSO):
"""
This XSO represents the ```` IQ payload from the generic pubsub
namespace (``http://jabber.org/protocol/pubsub``). It can carry different
types of payload.
.. attribute:: payload
This is the generic payload attribute. It supports the following
classes:
.. autosummary::
Affiliations
Create
Default
Items
Publish
Retract
Subscribe
Subscription
Subscriptions
Unsubscribe
.. attribute:: options
As :class:`Options` may both be used independently and along with
another :attr:`payload`, they have their own attribute.
Independent of their use, :class:`Options` objects are always available
here. If they are used without another payload, the :attr:`payload`
attribute is :data:`None`.
.. attribute:: configure
As :class:`Configure` may both be used independently and along with
another :attr:`payload`, it has its own attribute.
Independent of their use, :class:`Configure` objects are always
available here. If they are used without another payload, the
:attr:`payload` attribute is :data:`None`.
"""
TAG = (namespaces.xep0060, "pubsub")
payload = xso.Child([
Affiliations,
Create,
Default,
Items,
Publish,
Retract,
Subscribe,
Subscription,
Subscriptions,
Unsubscribe,
])
options = xso.Child([
Options,
])
configure = xso.Child([
Configure,
])
publish_options = xso.Child([
PublishOptions,
])
def __init__(self, payload=None):
super().__init__()
self.payload = payload
aioxmpp.stanza.Message.xep0060_request = xso.Child([
Request
])
class EventAssociate(xso.XSO):
TAG = (namespaces.xep0060_event, "associate")
node = xso.Attr(
"node",
)
class EventDisassociate(xso.XSO):
TAG = (namespaces.xep0060_event, "disassociate")
node = xso.Attr(
"node",
)
class EventCollection(xso.XSO):
TAG = (namespaces.xep0060_event, "collection")
assoc = xso.Child([
EventAssociate,
EventDisassociate,
])
class EventRedirect(xso.XSO):
TAG = (namespaces.xep0060_event, "redirect")
uri = xso.Attr(
"uri",
)
def __init__(self, uri):
super().__init__()
self.uri = uri
class EventDelete(xso.XSO):
TAG = (namespaces.xep0060_event, "delete")
_redirect = xso.Child([
EventRedirect,
])
node = xso.Attr(
"node",
)
def __init__(self, node, *, redirect_uri=None):
super().__init__()
self.node = node
if redirect_uri is not None:
self._redirect = EventRedirect(redirect_uri)
@property
def redirect_uri(self):
if self._redirect is None:
return None
return self._redirect.uri
@redirect_uri.setter
def redirect_uri(self, value):
if value is None:
del self._redirect
return
self._redirect = EventRedirect(value)
@redirect_uri.deleter
def redirect_uri(self):
del self._redirect
class EventRetract(xso.XSO):
TAG = (namespaces.xep0060_event, "retract")
id_ = xso.Attr(
"id",
)
def __init__(self, id_):
super().__init__()
self.id_ = id_
class EventItem(xso.XSO):
TAG = (namespaces.xep0060_event, "item")
id_ = xso.Attr(
"id",
default=None,
)
node = xso.Attr(
"node",
default=None,
)
publisher = xso.Attr(
"publisher",
default=None,
)
registered_payload = xso.Child([], strict=True)
unregistered_payload = xso.Collector()
def __init__(self, payload, *, id_=None):
super().__init__()
self.registered_payload = payload
self.id_ = id_
class EventItems(xso.XSO):
TAG = (namespaces.xep0060_event, "items")
node = xso.Attr(
"node",
)
retracts = xso.ChildList([EventRetract])
items = xso.ChildList([EventItem])
def __init__(self, node, *, items=[], retracts=[]):
super().__init__()
self.items[:] = items
self.retracts[:] = retracts
self.node = node
class EventPurge(xso.XSO):
TAG = (namespaces.xep0060_event, "purge")
node = xso.Attr(
"node",
)
class EventSubscription(xso.XSO):
TAG = (namespaces.xep0060_event, "subscription")
jid = xso.Attr(
"jid",
type_=xso.JID()
)
node = xso.Attr(
"node",
default=None
)
subid = xso.Attr(
"subid",
default=None
)
subscription = xso.Attr(
"subscription",
validator=xso.RestrictToSet({
"none",
"pending",
"subscribed",
"unconfigured",
}),
default=None
)
expiry = xso.Attr(
"expiry",
type_=xso.DateTime(),
)
class EventConfiguration(xso.XSO):
TAG = (namespaces.xep0060_event, "configuration")
node = xso.Attr(
"node",
default=None
)
data = xso.Child([
aioxmpp.forms.Data,
])
class Event(xso.XSO):
TAG = (namespaces.xep0060_event, "event")
payload = xso.Child([
EventCollection,
EventConfiguration,
EventDelete,
EventItems,
EventPurge,
EventSubscription,
])
def __init__(self, payload=None):
super().__init__()
self.payload = payload
aioxmpp.stanza.Message.xep0060_event = xso.Child([
Event
])
class OwnerAffiliation(xso.XSO):
TAG = (namespaces.xep0060_owner, "affiliation")
affiliation = xso.Attr(
"affiliation",
validator=xso.RestrictToSet({
"member",
"outcast",
"owner",
"publisher",
"publish-only",
"none",
}),
validate=xso.ValidateMode.ALWAYS,
)
jid = xso.Attr(
"jid",
type_=xso.JID(),
)
def __init__(self, jid, affiliation):
super().__init__()
self.jid = jid
self.affiliation = affiliation
class OwnerAffiliations(xso.XSO):
TAG = (namespaces.xep0060_owner, "affiliations")
affiliations = xso.ChildList([OwnerAffiliation])
node = xso.Attr(
"node",
)
def __init__(self, node, *, affiliations=[]):
super().__init__()
self.node = node
self.affiliations[:] = affiliations
class OwnerConfigure(xso.XSO):
TAG = (namespaces.xep0060_owner, "configure")
node = xso.Attr(
"node",
default=None,
)
data = xso.Child([
aioxmpp.forms.Data,
])
def __init__(self, node=None):
super().__init__()
self.node = node
class OwnerDefault(xso.XSO):
TAG = (namespaces.xep0060_owner, "default")
data = xso.Child([
aioxmpp.forms.Data,
])
class OwnerRedirect(xso.XSO):
TAG = (namespaces.xep0060_owner, "redirect")
uri = xso.Attr(
"uri",
)
def __init__(self, uri):
super().__init__()
self.uri = uri
class OwnerDelete(xso.XSO):
TAG = (namespaces.xep0060_owner, "delete")
_redirect = xso.Child([OwnerRedirect])
node = xso.Attr(
"node",
)
def __init__(self, node, *, redirect_uri=None):
super().__init__()
self.node = node
if redirect_uri is not None:
self._redirect = OwnerRedirect(redirect_uri)
@property
def redirect_uri(self):
if self._redirect is None:
return None
return self._redirect.uri
@redirect_uri.setter
def redirect_uri(self, value):
if value is None:
del self._redirect
return
self._redirect = OwnerRedirect(value)
@redirect_uri.deleter
def redirect_uri(self):
del self._redirect
class OwnerPurge(xso.XSO):
TAG = (namespaces.xep0060_owner, "purge")
node = xso.Attr(
"node",
)
def __init__(self, node):
super().__init__()
self.node = node
class OwnerSubscription(xso.XSO):
TAG = (namespaces.xep0060_owner, "subscription")
subscription = xso.Attr(
"subscription",
validator=xso.RestrictToSet({
"none",
"pending",
"subscribed",
"unconfigured",
}),
validate=xso.ValidateMode.ALWAYS
)
jid = xso.Attr(
"jid",
type_=xso.JID(),
)
def __init__(self, jid, subscription):
super().__init__()
self.jid = jid
self.subscription = subscription
class OwnerSubscriptions(xso.XSO):
TAG = (namespaces.xep0060_owner, "subscriptions")
node = xso.Attr(
"node",
)
subscriptions = xso.ChildList([OwnerSubscription])
def __init__(self, node, *, subscriptions=[]):
super().__init__()
self.node = node
self.subscriptions[:] = subscriptions
@aioxmpp.stanza.IQ.as_payload_class
class OwnerRequest(xso.XSO):
TAG = (namespaces.xep0060_owner, "pubsub")
payload = xso.Child([
OwnerAffiliations,
OwnerConfigure,
OwnerDefault,
OwnerDelete,
OwnerPurge,
OwnerSubscriptions,
])
def __init__(self, payload):
super().__init__()
self.payload = payload
def as_payload_class(cls):
"""
Register the given class `cls` as Publish-Subscribe payload on both
:class:`Item` and :class:`EventItem`.
Return the class, to allow this to be used as decorator.
"""
Item.register_child(
Item.registered_payload,
cls,
)
EventItem.register_child(
EventItem.registered_payload,
cls,
)
return cls
ClosedNode = aioxmpp.stanza.make_application_error(
"ClosedNode",
(namespaces.xep0060_errors, "closed-node"),
)
ConfigurationRequired = aioxmpp.stanza.make_application_error(
"ConfigurationRequired",
(namespaces.xep0060_errors, "configuration-required"),
)
InvalidJID = aioxmpp.stanza.make_application_error(
"InvalidJID",
(namespaces.xep0060_errors, "invalid-jid"),
)
InvalidOptions = aioxmpp.stanza.make_application_error(
"InvalidOptions",
(namespaces.xep0060_errors, "invalid-options"),
)
InvalidPayload = aioxmpp.stanza.make_application_error(
"InvalidPayload",
(namespaces.xep0060_errors, "invalid-payload"),
)
InvalidSubID = aioxmpp.stanza.make_application_error(
"InvalidSubID",
(namespaces.xep0060_errors, "invalid-subid"),
)
ItemForbidden = aioxmpp.stanza.make_application_error(
"ItemForbidden",
(namespaces.xep0060_errors, "item-forbidden"),
)
ItemRequired = aioxmpp.stanza.make_application_error(
"ItemRequired",
(namespaces.xep0060_errors, "item-required"),
)
JIDRequired = aioxmpp.stanza.make_application_error(
"JIDRequired",
(namespaces.xep0060_errors, "jid-required"),
)
MaxItemsExceeded = aioxmpp.stanza.make_application_error(
"MaxItemsExceeded",
(namespaces.xep0060_errors, "max-items-exceeded"),
)
MaxNodesExceeded = aioxmpp.stanza.make_application_error(
"MaxNodesExceeded",
(namespaces.xep0060_errors, "max-nodes-exceeded"),
)
NodeIDRequired = aioxmpp.stanza.make_application_error(
"NodeIDRequired",
(namespaces.xep0060_errors, "nodeid-required"),
)
NotInRosterGroup = aioxmpp.stanza.make_application_error(
"NotInRosterGroup",
(namespaces.xep0060_errors, "not-in-roster-group"),
)
NotSubscribed = aioxmpp.stanza.make_application_error(
"NotSubscribed",
(namespaces.xep0060_errors, "not-subscribed"),
)
PayloadTooBig = aioxmpp.stanza.make_application_error(
"PayloadTooBig",
(namespaces.xep0060_errors, "payload-too-big"),
)
PayloadRequired = aioxmpp.stanza.make_application_error(
"PayloadRequired",
(namespaces.xep0060_errors, "payload-required"),
)
PendingSubscription = aioxmpp.stanza.make_application_error(
"PendingSubscription",
(namespaces.xep0060_errors, "pending-subscription"),
)
PresenceSubscriptionRequired = aioxmpp.stanza.make_application_error(
"PresenceSubscriptionRequired",
(namespaces.xep0060_errors, "presence-subscription-required"),
)
SubIDRequired = aioxmpp.stanza.make_application_error(
"SubIDRequired",
(namespaces.xep0060_errors, "subid-required"),
)
TooManySubscriptions = aioxmpp.stanza.make_application_error(
"TooManySubscriptions",
(namespaces.xep0060_errors, "too-many-subscriptions"),
)
@aioxmpp.stanza.Error.as_application_condition
class Unsupported(xso.XSO):
TAG = (namespaces.xep0060_errors, "unsupported")
feature = xso.Attr(
"feature",
validator=xso.RestrictToSet({
"access-authorize",
"access-open",
"access-presence",
"access-roster",
"access-whitelist",
"auto-create",
"auto-subscribe",
"collections",
"config-node",
"create-and-configure",
"create-nodes",
"delete-items",
"delete-nodes",
"filtered-notifications",
"get-pending",
"instant-nodes",
"item-ids",
"last-published",
"leased-subscription",
"manage-subscriptions",
"member-affiliation",
"meta-data",
"modify-affiliations",
"multi-collection",
"multi-subscribe",
"outcast-affiliation",
"persistent-items",
"presence-notifications",
"presence-subscribe",
"publish",
"publish-options",
"publish-only-affiliation",
"publisher-affiliation",
"purge-nodes",
"retract-items",
"retrieve-affiliations",
"retrieve-default",
"retrieve-items",
"retrieve-subscriptions",
"subscribe",
"subscription-options",
"subscription-notifications",
})
)
# foo
Feature.__docstring___ = \
"""
.. attribute:: ACCESS_AUTHORIZE
:annotation: = "http://jabber.org/protocol/pubsub#access-authorize"
The default node access model is authorize.
.. attribute:: ACCESS_OPEN
:annotation: = "http://jabber.org/protocol/pubsub#access-open"
The default node access model is open.
.. attribute:: ACCESS_PRESENCE
:annotation: = "http://jabber.org/protocol/pubsub#access-presence"
The default node access model is presence.
.. attribute:: ACCESS_ROSTER
:annotation: = "http://jabber.org/protocol/pubsub#access-roster"
The default node access model is roster.
.. attribute:: ACCESS_WHITELIST
:annotation: = "http://jabber.org/protocol/pubsub#access-whitelist"
The default node access model is whitelist.
.. attribute:: AUTO_CREATE
:annotation: = "http://jabber.org/protocol/pubsub#auto-create"
The service supports automatic creation of nodes on first publish.
.. attribute:: AUTO_SUBSCRIBE
:annotation: = "http://jabber.org/protocol/pubsub#auto-subscribe"
The service supports automatic subscription to a nodes based on presence subscription.
.. attribute:: COLLECTIONS
:annotation: = "http://jabber.org/protocol/pubsub#collections"
Collection nodes are supported.
.. attribute:: CONFIG_NODE
:annotation: = "http://jabber.org/protocol/pubsub#config-node"
Configuration of node options is supported.
.. attribute:: CREATE_AND_CONFIGURE
:annotation: = "http://jabber.org/protocol/pubsub#create-and-configure"
Simultaneous creation and configuration of nodes is supported.
.. attribute:: CREATE_NODES
:annotation: = "http://jabber.org/protocol/pubsub#create-nodes"
Creation of nodes is supported.
.. attribute:: DELETE_ITEMS
:annotation: = "http://jabber.org/protocol/pubsub#delete-items"
Deletion of items is supported.
.. attribute:: DELETE_NODES
:annotation: = "http://jabber.org/protocol/pubsub#delete-nodes"
Deletion of nodes is supported.
.. attribute:: FILTERED_NOTIFICATIONS
:annotation: = "http://jabber.org/protocol/pubsub#filtered-notifications"
The service supports filtering of notifications based on Entity Capabilities.
.. attribute:: GET_PENDING
:annotation: = "http://jabber.org/protocol/pubsub#get-pending"
Retrieval of pending subscription approvals is supported.
.. attribute:: INSTANT_NODES
:annotation: = "http://jabber.org/protocol/pubsub#instant-nodes"
Creation of instant nodes is supported.
.. attribute:: ITEM_IDS
:annotation: = "http://jabber.org/protocol/pubsub#item-ids"
Publishers may specify item identifiers.
.. attribute:: LAST_PUBLISHED
:annotation: = "http://jabber.org/protocol/pubsub#last-published"
The service supports sending of the last published item to new subscribers and to newly available resources.
.. attribute:: LEASED_SUBSCRIPTION
:annotation: = "http://jabber.org/protocol/pubsub#leased-subscription"
Time-based subscriptions are supported.
.. attribute:: MANAGE_SUBSCRIPTIONS
:annotation: = "http://jabber.org/protocol/pubsub#manage-subscriptions"
Node owners may manage subscriptions.
.. attribute:: MEMBER_AFFILIATION
:annotation: = "http://jabber.org/protocol/pubsub#member-affiliation"
The member affiliation is supported.
.. attribute:: META_DATA
:annotation: = "http://jabber.org/protocol/pubsub#meta-data"
Node meta-data is supported.
.. attribute:: MODIFY_AFFILIATIONS
:annotation: = "http://jabber.org/protocol/pubsub#modify-affiliations"
Node owners may modify affiliations.
.. attribute:: MULTI_COLLECTION
:annotation: = "http://jabber.org/protocol/pubsub#multi-collection"
A single leaf node can be associated with multiple collections.
.. attribute:: MULTI_SUBSCRIBE
:annotation: = "http://jabber.org/protocol/pubsub#multi-subscribe"
A single entity may subscribe to a node multiple times.
.. attribute:: OUTCAST_AFFILIATION
:annotation: = "http://jabber.org/protocol/pubsub#outcast-affiliation"
The outcast affiliation is supported.
.. attribute:: PERSISTENT_ITEMS
:annotation: = "http://jabber.org/protocol/pubsub#persistent-items"
Persistent items are supported.
.. attribute:: PRESENCE_NOTIFICATIONS
:annotation: = "http://jabber.org/protocol/pubsub#presence-notifications"
Presence-based delivery of event notifications is supported.
.. attribute:: PRESENCE_SUBSCRIBE
:annotation: = "http://jabber.org/protocol/pubsub#presence-subscribe"
Implicit presence-based subscriptions are supported.
.. attribute:: PUBLISH
:annotation: = "http://jabber.org/protocol/pubsub#publish"
Publishing items is supported.
.. attribute:: PUBLISH_OPTIONS
:annotation: = "http://jabber.org/protocol/pubsub#publish-options"
Publication with publish options is supported.
.. attribute:: PUBLISH_ONLY_AFFILIATION
:annotation: = "http://jabber.org/protocol/pubsub#publish-only-affiliation"
The publish-only affiliation is supported.
.. attribute:: PUBLISHER_AFFILIATION
:annotation: = "http://jabber.org/protocol/pubsub#publisher-affiliation"
The publisher affiliation is supported.
.. attribute:: PURGE_NODES
:annotation: = "http://jabber.org/protocol/pubsub#purge-nodes"
Purging of nodes is supported.
.. attribute:: RETRACT_ITEMS
:annotation: = "http://jabber.org/protocol/pubsub#retract-items"
Item retraction is supported.
.. attribute:: RETRIEVE_AFFILIATIONS
:annotation: = "http://jabber.org/protocol/pubsub#retrieve-affiliations"
Retrieval of current affiliations is supported.
.. attribute:: RETRIEVE_DEFAULT
:annotation: = "http://jabber.org/protocol/pubsub#retrieve-default"
Retrieval of default node configuration is supported.
.. attribute:: RETRIEVE_ITEMS
:annotation: = "http://jabber.org/protocol/pubsub#retrieve-items"
Item retrieval is supported.
.. attribute:: RETRIEVE_SUBSCRIPTIONS
:annotation: = "http://jabber.org/protocol/pubsub#retrieve-subscriptions"
Retrieval of current subscriptions is supported.
.. attribute:: SUBSCRIBE
:annotation: = "http://jabber.org/protocol/pubsub#subscribe"
Subscribing and unsubscribing are supported.
.. attribute:: SUBSCRIPTION_OPTIONS
:annotation: = "http://jabber.org/protocol/pubsub#subscription-options"
Configuration of subscription options is supported.
.. attribute:: SUBSCRIPTION_NOTIFICATIONS
:annotation: = "http://jabber.org/protocol/pubsub#subscription-notifications"
Notification of subscription state changes is supported.
""" # NOQA: E501
class NodeConfigForm(aioxmpp.forms.Form):
"""
Declaration of the form with type
``http://jabber.org/protocol/pubsub#node_config``
.. autoattribute:: access_model
.. autoattribute:: body_xslt
.. autoattribute:: children_association_policy
.. autoattribute:: children_association_whitelist
.. autoattribute:: children
.. autoattribute:: children_max
.. autoattribute:: collection
.. autoattribute:: contact
.. autoattribute:: dataform_xslt
.. autoattribute:: deliver_notifications
.. autoattribute:: deliver_payloads
.. autoattribute:: description
.. autoattribute:: item_expire
.. autoattribute:: itemreply
.. autoattribute:: language
.. autoattribute:: max_items
.. autoattribute:: max_payload_size
.. autoattribute:: node_type
.. autoattribute:: notification_type
.. autoattribute:: notify_config
.. autoattribute:: notify_delete
.. autoattribute:: notify_retract
.. autoattribute:: notify_sub
.. autoattribute:: persist_items
.. autoattribute:: presence_based_delivery
.. autoattribute:: publish_model
.. autoattribute:: purge_offline
.. autoattribute:: roster_groups_allowed
.. autoattribute:: send_last_published_item
.. autoattribute:: tempsub
.. autoattribute:: subscribe
.. autoattribute:: title
.. autoattribute:: type
"""
FORM_TYPE = 'http://jabber.org/protocol/pubsub#node_config'
access_model = aioxmpp.forms.ListSingle(
var='pubsub#access_model',
label='Who may subscribe and retrieve items'
)
body_xslt = aioxmpp.forms.TextSingle(
var='pubsub#body_xslt',
label='The URL of an XSL transformation which can be applied to '
'payloads in order to generate an appropriate message body element.'
)
children_association_policy = aioxmpp.forms.ListSingle(
var='pubsub#children_association_policy',
label='Who may associate leaf nodes with a collection'
)
children_association_whitelist = aioxmpp.forms.JIDMulti(
var='pubsub#children_association_whitelist',
label='The list of JIDs that may associate leaf nodes with a '
'collection'
)
children = aioxmpp.forms.TextMulti(
var='pubsub#children',
label='The child nodes (leaf or collection) associated with a '
'collection'
)
children_max = aioxmpp.forms.TextSingle(
var='pubsub#children_max',
label='The maximum number of child nodes that can be associated with '
'a collection'
)
collection = aioxmpp.forms.TextMulti(
var='pubsub#collection',
label='The collection(s) with which a node is affiliated'
)
contact = aioxmpp.forms.JIDMulti(
var='pubsub#contact',
label='The JIDs of those to contact with questions'
)
dataform_xslt = aioxmpp.forms.TextSingle(
var='pubsub#dataform_xslt',
label='The URL of an XSL transformation which can be applied to the '
'payload format in order to generate a valid Data Forms result that '
'the client could display using a generic Data Forms rendering engine'
)
deliver_notifications = aioxmpp.forms.Boolean(
var='pubsub#deliver_notifications',
label='Whether to deliver event notifications'
)
deliver_payloads = aioxmpp.forms.Boolean(
var='pubsub#deliver_payloads',
label='Whether to deliver payloads with event notifications; applies '
'only to leaf nodes'
)
description = aioxmpp.forms.TextSingle(
var='pubsub#description',
label='A description of the node'
)
item_expire = aioxmpp.forms.TextSingle(
var='pubsub#item_expire',
label='Number of seconds after which to automatically purge items'
)
itemreply = aioxmpp.forms.ListSingle(
var='pubsub#itemreply',
label='Whether owners or publisher should receive replies to items'
)
language = aioxmpp.forms.ListSingle(
var='pubsub#language',
label='The default language of the node'
)
max_items = aioxmpp.forms.TextSingle(
var='pubsub#max_items',
label='The maximum number of items to persist'
)
max_payload_size = aioxmpp.forms.TextSingle(
var='pubsub#max_payload_size',
label='The maximum payload size in bytes'
)
node_type = aioxmpp.forms.ListSingle(
var='pubsub#node_type',
label='Whether the node is a leaf (default) or a collection'
)
notification_type = aioxmpp.forms.ListSingle(
var='pubsub#notification_type',
label='Specify the delivery style for notifications'
)
notify_config = aioxmpp.forms.Boolean(
var='pubsub#notify_config',
label='Whether to notify subscribers when the node configuration '
'changes'
)
notify_delete = aioxmpp.forms.Boolean(
var='pubsub#notify_delete',
label='Whether to notify subscribers when the node is deleted'
)
notify_retract = aioxmpp.forms.Boolean(
var='pubsub#notify_retract',
label='Whether to notify subscribers when items are removed from the '
'node'
)
notify_sub = aioxmpp.forms.Boolean(
var='pubsub#notify_sub',
label='Whether to notify owners about new subscribers and unsubscribes'
)
persist_items = aioxmpp.forms.Boolean(
var='pubsub#persist_items',
label='Whether to persist items to storage'
)
presence_based_delivery = aioxmpp.forms.Boolean(
var='pubsub#presence_based_delivery',
label='Whether to deliver notifications to available users only'
)
publish_model = aioxmpp.forms.ListSingle(
var='pubsub#publish_model',
label='The publisher model'
)
purge_offline = aioxmpp.forms.Boolean(
var='pubsub#purge_offline',
label='Whether to purge all items when the relevant publisher goes '
'offline'
)
roster_groups_allowed = aioxmpp.forms.ListMulti(
var='pubsub#roster_groups_allowed',
label='The roster group(s) allowed to subscribe and retrieve items'
)
send_last_published_item = aioxmpp.forms.ListSingle(
var='pubsub#send_last_published_item',
label='When to send the last published item'
)
tempsub = aioxmpp.forms.Boolean(
var='pubsub#tempsub',
label='Whether to make all subscriptions temporary, based on '
'subscriber presence'
)
subscribe = aioxmpp.forms.Boolean(
var='pubsub#subscribe',
label='Whether to allow subscriptions'
)
title = aioxmpp.forms.TextSingle(
var='pubsub#title',
label='A friendly name for the node'
)
type = aioxmpp.forms.TextSingle(
var='pubsub#type',
label='The type of node data, usually specified by the namespace of '
'the payload (if any)'
)
aioxmpp/rfc3921.py 0000664 0000000 0000000 00000004005 14160146213 0014173 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: rfc3921.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.rfc3921` --- XSOs for legacy protocol parts
##########################################################
This module was introduced to ensure compatibility with legacy XMPP servers
(such as ejabberd).
.. autoclass:: Session
.. autoclass:: SessionFeature
"""
from . import stanza, nonza, xso
from .utils import namespaces
namespaces.rfc3921_session = "urn:ietf:params:xml:ns:xmpp-session"
@stanza.IQ.as_payload_class
class Session(xso.XSO):
"""
IQ payload to establish a legacy XMPP session.
.. versionadded:: 0.4
"""
UNKNOWN_CHILD_POLICY = xso.UnknownChildPolicy.DROP
UNKNOWN_ATTR_POLICY = xso.UnknownAttrPolicy.DROP
TAG = (namespaces.rfc3921_session, "session")
@nonza.StreamFeatures.as_feature_class
class SessionFeature(xso.XSO):
"""
Stream feature which the server uses to announce that it supports legacy
XMPP sessions.
.. versionadded:: 0.4
"""
UNKNOWN_CHILD_POLICY = xso.UnknownChildPolicy.DROP
UNKNOWN_ATTR_POLICY = xso.UnknownAttrPolicy.DROP
TAG = (namespaces.rfc3921_session, "session")
optional = xso.ChildFlag(
(namespaces.rfc3921_session, "optional")
)
aioxmpp/rfc6120.py 0000664 0000000 0000000 00000005741 14160146213 0014175 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: rfc6120.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.rfc6120` -- Stanza payloads for :rfc:`6120` implementation
#########################################################################
This module contains XSO-related classes for implementation of :rfc:`6120`.
.. autoclass:: BindFeature
.. autoclass:: Bind
.. autoclass:: Required
"""
from . import xso, stanza, nonza
from .utils import namespaces
namespaces.rfc6120_bind = "urn:ietf:params:xml:ns:xmpp-bind"
class Required(xso.XSO):
"""
The XSO used for the :attr:`.BindFeature.required` attribute.
"""
TAG = (namespaces.rfc6120_bind, "required")
@nonza.StreamFeatures.as_feature_class
class BindFeature(xso.XSO):
"""
A stream feature for use with :class:`.nonza.StreamFeatures` which
indicates that the server allows resource binding.
.. attribute:: required
This attribute is either an instance of :class:`Required` or
:data:`None`. The former indicates that the server requires resource
binding at this point in the stream negotiation; :data:`None` indicates
that it is not required.
User code should just test the boolean value of this attribute and not
worry about the actual types involved.
"""
TAG = (namespaces.rfc6120_bind, "bind")
required = xso.Child([Required], required=False)
class Bind(xso.XSO):
"""
The :class:`.IQ` payload for binding to a resource.
.. attribute:: jid
The server-supplied :class:`aioxmpp.JID`. This must not be set by
client code.
.. attribute:: resource
The client-supplied, optional resource. If a client wishes to bind to a
specific resource, it must tell the server that using this attribute.
"""
TAG = (namespaces.rfc6120_bind, "bind")
jid = xso.ChildText(
(namespaces.rfc6120_bind, "jid"),
type_=xso.JID(),
default=None
)
resource = xso.ChildText(
(namespaces.rfc6120_bind, "resource"),
default=None
)
def __init__(self, jid=None, resource=None):
super().__init__()
self.jid = jid
self.resource = resource
stanza.IQ.register_child(stanza.IQ.payload, Bind)
aioxmpp/roster/ 0000775 0000000 0000000 00000000000 14160146213 0014047 5 ustar 00root root 0000000 0000000 aioxmpp/roster/__init__.py 0000664 0000000 0000000 00000003630 14160146213 0016162 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.roster` --- :rfc:`6121` roster implementation
############################################################
This subpackage provides :class:`.RosterClient`, a service to interact with
:rfc:`6121` rosters.
.. currentmodule:: aioxmpp
.. autoclass:: RosterClient
.. currentmodule:: aioxmpp.roster
.. class:: Service
Alias of :class:`.RosterClient`.
.. deprecated:: 0.8
The alias will be removed in 1.0.
.. autoclass:: Item
.. module:: aioxmpp.roster.xso
.. currentmodule:: aioxmpp.roster.xso
:mod:`.roster.xso` --- IQ payloads and stream feature
=====================================================
The submodule :mod:`aioxmpp.roster.xso` contains the :class:`~aioxmpp.xso.XSO`
classes which describe the IQ payloads used by this subpackage.
.. autoclass:: Query
.. autoclass:: Item
.. autoclass:: Group
The stream feature which is used by servers to announce support for roster
versioning:
.. autoclass:: RosterVersioningFeature()
"""
from .service import RosterClient, Item # NOQA: F401
Service = RosterClient # NOQA
aioxmpp/roster/service.py 0000664 0000000 0000000 00000062022 14160146213 0016063 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import logging
import aioxmpp.service
import aioxmpp.callbacks as callbacks
import aioxmpp.errors as errors
import aioxmpp.stanza as stanza
import aioxmpp.structs as structs
from . import xso as roster_xso
logger = logging.getLogger(__name__)
_Sentinel = object()
class Item:
"""
Represent an entry in the roster. These entries are mutable, see the
documentation of :class:`Service` for details on the lifetime of
:class:`Item` instances within a :class:`Service` instance.
.. attribute:: jid
The :class:`~aioxmpp.JID` of the entry. This is always a bare
JID.
.. attribute:: name
The display name of the entry, if any.
.. attribute:: groups
A :class:`set` of names of groups in which the roster entry is.
.. attribute:: subscription
The subscription status of the entry. One of ``"none"``, ``"to"``,
``"from"`` and ``"both"`` (in contrast to :class:`.xso.Item`,
``"remove"`` cannot occur here).
.. attribute:: ask
The ``ask`` attribute of the roster entry.
.. attribute:: approved
The ``approved`` attribute of the roster entry.
The data of a roster entry can conveniently be exported to JSON:
.. automethod:: export_as_json
To mutate the roster entry, some handy methods are provided:
.. automethod:: update_from_json
.. automethod:: update_from_xso_item
To create a roster entry from a :class:`.xso.Item`, use the
:meth:`from_xso_item` class method.
.. automethod:: from_xso_item
.. note::
Do not confuse this with the XSO :class:`.xso.Item`.
"""
def __init__(self, jid, *,
approved=False,
ask=None,
subscription="none",
name=None,
groups=()):
super().__init__()
self.jid = jid
self.subscription = subscription
self.approved = approved
self.ask = ask
self.name = name
self.groups = set(groups)
def update_from_xso_item(self, xso_item):
"""
Update the attributes (except :attr:`jid`) with the values obtained
from the gixen `xso_item`.
`xso_item` must be a valid :class:`.xso.Item` instance.
"""
self.subscription = xso_item.subscription
self.approved = xso_item.approved
self.ask = xso_item.ask
self.name = xso_item.name
self.groups = {group.name for group in xso_item.groups}
@classmethod
def from_xso_item(cls, xso_item):
"""
Create a :class:`Item` with the :attr:`jid` set to the
:attr:`.xso.Item.jid` obtained from `xso_item`. Then update that
instance with `xso_item` using :meth:`update_from_xso_item` and return
it.
"""
item = cls(xso_item.jid)
item.update_from_xso_item(xso_item)
return item
def export_as_json(self):
"""
Return a :mod:`json`-compatible dictionary which contains the
attributes of this :class:`Item` except its JID.
"""
result = {
"subscription": self.subscription,
}
if self.name:
result["name"] = self.name
if self.ask is not None:
result["ask"] = self.ask
if self.approved:
result["approved"] = self.approved
if self.groups:
result["groups"] = sorted(self.groups)
return result
def update_from_json(self, data):
"""
Update the attributes of this :class:`Item` using the values obtained
from the dictionary `data`.
The format of `data` should be the same as the format returned by
:meth:`export_as_json`.
"""
self.subscription = data.get("subscription", "none")
self.approved = bool(data.get("approved", False))
self.ask = data.get("ask", None)
self.name = data.get("name", None)
self.groups = set(data.get("groups", []))
class RosterClient(aioxmpp.service.Service):
"""
A roster client :class:`aioxmpp.service.Service`.
The interaction with a roster service happens mainly by accessing the
attributes holding the state and using the events to be notified of state
changes:
Attributes for accessing the roster:
.. attribute:: items
A dictionary mapping :class:`~aioxmpp.JID` instances to corresponding
:class:`Item` instances.
.. attribute:: groups
A dictionary which allows group-based access to :class:`Item`
instances. The dictionaries keys are the names of the groups, the values
are :class:`set` instances, which hold the :class:`Item` instances in
that group.
At no point one can observe empty :class:`set` instances in this
dictionary.
The :class:`Item` instances stay the same, as long as they represent the
identical roster entry on the remote side. That is, if the name or
subscription state are changed in the server side roster, the :class:`Item`
instance stays the same, but the attributes are mutated. However, if the
entry is removed from the server roster and re-added later for the same
JID, it will be a different :class:`Item` instance.
Signals:
.. signal:: on_initial_roster_received()
Fires when the initial roster has been received. Note that if roster
versioning is used, the initial roster may not be up-to-date. The server
is allowed to tell the client to re-use its local state and deliver
changes using roster pushes. In that case, the
:meth:`on_initial_roster_received` event fires immediately, so that the
user sees whatever roster has been set up for versioning before the
stream was established; updates pushed by the server are delivered using
the normal events.
The roster data has already been imported at the time the callback is
fired.
Note that the initial roster is diffed against whatever is in the local
store and events are fired just like for normal push updates. Thus, in
general, you won’t need this signal; it might be better to listen for
the events below.
.. signal:: on_entry_added(item)
Fires when an `item` has been added to the roster. The attributes of the
`item` are up-to-date when this callback fires.
When the event fires, the bookkeeping structures are already updated.
This implies that :meth:`on_group_added` is called before
:meth:`on_entry_added` if the entry adds a new group.
.. signal:: on_entry_name_changed(item)
Fires when a roster update changed the name of the `item`. The new name
is already applied to the `item`.
.. signal:: on_entry_subscription_state_changed(item)
Fires when a roster update changes any of the :attr:`Item.subscription`,
:attr:`Item.ask` or :attr:`Item.approved` attributes. The new values are
already applied to `item`.
The event always fires once per update, even if the update changes
more than one of the above attributes.
.. signal:: on_entry_added_to_group(item, group_name)
Fires when an update adds an `item` to a group. The :attr:`Item.groups`
attribute is already updated (not only with this, but also other group
updates, including removals) when this event is fired.
The event fires for each added group in an update, thus it may fire more
than once per update.
The name of the new group is in `group_name`.
At the time the event fires, the bookkeeping structures for the group
are already updated; this implies that :meth:`on_group_added` fires
*before* :meth:`on_entry_added_to_group` if the entry added a new group.
.. signal:: on_entry_removed_from_group(item, group_name)
Fires when an update removes an `item` from a group. The
:attr:`Item.groups` attribute is already updated (not only with this,
but also other group updates, including additions) when this event is
fired.
The event fires for each removed group in an update, thus it may fire
more than once per update.
The name of the new group is in `group_name`.
At the time the event fires, the bookkeeping structures are already
updated; this implies that :meth:`on_group_removed` fires *before*
:meth:`on_entry_removed_from_group` if the removal of an entry from a
group causes the group to vanish.
.. signal:: on_entry_removed(item)
Fires after an entry has been removed from the roster. The entry is
already removed from all bookkeeping structures, but the values on the
`item` object are the same as right before the removal.
This implies that :meth:`on_group_removed` fires *before*
:meth:`on_entry_removed` if the removal of an entry causes a group to
vanish.
.. signal:: on_group_added(group)
Fires after a new group has been added to the bookkeeping structures.
:param group: Name of the new group.
:type group: :class:`str`
At the time the event fires, the group is empty.
.. versionadded:: 0.9
.. signal:: on_group_removed(group)
Fires after a new group has been removed from the bookkeeping
structures.
:param group: Name of the old group.
:type group: :class:`str`
At the time the event fires, the group is empty.
.. versionadded:: 0.9
Modifying roster contents:
.. automethod:: set_entry
.. automethod:: remove_entry
Managing presence subscriptions:
.. automethod:: approve
.. automethod:: subscribe
.. signal:: on_subscribe(stanza)
Fires when a peer requested a subscription. The whole stanza received is
included as `stanza`.
.. seealso::
To approve a subscription request, use :meth:`approve`.
.. signal:: on_subscribed(stanza)
Fires when a peer has confirmed a previous subscription request. The
``"subscribed"`` stanza is included as `stanza`.
.. signal:: on_unsubscribe(stanza)
Fires when a peer cancelled their subscription for our presence. As per
:rfc:`6121`, the server forwards the ``"unsubscribe"`` presence stanza
(which is included as `stanza` argument) *before* sending the roster
push.
Unless your application is interested in the specific cause of a
subscription state change, it is not necessary to use this signal; the
subscription state change will be covered by
:meth:`on_entry_subscription_state_changed`.
.. signal:: on_unsubscribed(stanza)
Fires when a peer cancelled our subscription. As per :rfc:`6121`, the
server forwards the ``"unsubscribed"`` presence stanza (which is
included as `stanza` argument) *before* sending the roster push.
Unless your application is interested in the specific cause of a
subscription state change, it is not necessary to use this signal; the
subscription state change will be covered by
:meth:`on_entry_subscription_state_changed`.
Import/Export of roster data:
.. automethod:: export_as_json
.. automethod:: import_from_json
To make use of roster versioning, use the above two methods. The general
workflow is to :meth:`export_as_json` the roster after disconnecting and
storing it for the next connection attempt. **Before** connecting, the
stored data needs to be loaded using :meth:`import_from_json`. This only
needs to happen after a new :class:`Service` has been created, as roster
services won’t delete roster contents between two connections on the same
:class:`.Client` instance.
.. versionchanged:: 0.8
This class was formerly known as :class:`aioxmpp.roster.Service`. It
is still available under that name, but the alias will be removed in
1.0.
"""
ORDER_AFTER = [
aioxmpp.dispatcher.SimplePresenceDispatcher,
]
on_initial_roster_received = callbacks.Signal()
on_entry_name_changed = callbacks.Signal()
on_entry_subscription_state_changed = callbacks.Signal()
on_entry_removed = callbacks.Signal()
on_entry_added = callbacks.Signal()
on_entry_added_to_group = callbacks.Signal()
on_entry_removed_from_group = callbacks.Signal()
on_group_added = callbacks.Signal()
on_group_removed = callbacks.Signal()
on_subscribed = callbacks.Signal()
on_subscribe = callbacks.Signal()
on_unsubscribed = callbacks.Signal()
on_unsubscribe = callbacks.Signal()
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._bse_token = client.before_stream_established.connect(
self._request_initial_roster
)
self.__roster_lock = asyncio.Lock()
self.items = {}
self.groups = {}
self.version = None
def _update_entry(self, xso_item):
try:
stored_item = self.items[xso_item.jid]
except KeyError:
stored_item = Item.from_xso_item(xso_item)
self.items[xso_item.jid] = stored_item
for group in stored_item.groups:
try:
group_members = self.groups[group]
except KeyError:
group_members = self.groups.setdefault(group, set())
self.on_group_added(group)
group_members.add(stored_item)
self.on_entry_added(stored_item)
return
to_call = []
if stored_item.name != xso_item.name:
to_call.append(self.on_entry_name_changed)
if (stored_item.subscription != xso_item.subscription or
stored_item.approved != xso_item.approved or
stored_item.ask != xso_item.ask):
to_call.append(self.on_entry_subscription_state_changed)
old_groups = set(stored_item.groups)
stored_item.update_from_xso_item(xso_item)
new_groups = set(stored_item.groups)
removed_from_groups = old_groups - new_groups
added_to_groups = new_groups - old_groups
for cb in to_call:
cb(stored_item)
for group in added_to_groups:
try:
group_members = self.groups[group]
except KeyError:
group_members = self.groups.setdefault(group, set())
self.on_group_added(group)
group_members.add(stored_item)
self.on_entry_added_to_group(stored_item, group)
for group in removed_from_groups:
groupset = self.groups[group]
groupset.remove(stored_item)
if not groupset:
del self.groups[group]
self.on_group_removed(group)
self.on_entry_removed_from_group(stored_item, group)
@aioxmpp.service.iq_handler(
aioxmpp.structs.IQType.SET,
roster_xso.Query)
async def handle_roster_push(self, iq):
if iq.from_ and iq.from_ != self.client.local_jid.bare():
raise errors.XMPPAuthError(errors.ErrorCondition.FORBIDDEN)
request = iq.payload
async with self.__roster_lock:
for item in request.items:
if item.subscription == "remove":
try:
old_item = self.items.pop(item.jid)
except KeyError:
pass
else:
self._remove_from_groups(old_item, old_item.groups)
self.on_entry_removed(old_item)
else:
self._update_entry(item)
self.version = request.ver
@aioxmpp.dispatcher.presence_handler(
aioxmpp.structs.PresenceType.SUBSCRIBE,
None)
def handle_subscribe(self, stanza):
self.on_subscribe(stanza)
@aioxmpp.dispatcher.presence_handler(
aioxmpp.structs.PresenceType.SUBSCRIBED,
None)
def handle_subscribed(self, stanza):
self.on_subscribed(stanza)
@aioxmpp.dispatcher.presence_handler(
aioxmpp.structs.PresenceType.UNSUBSCRIBED,
None)
def handle_unsubscribed(self, stanza):
self.on_unsubscribed(stanza)
@aioxmpp.dispatcher.presence_handler(
aioxmpp.structs.PresenceType.UNSUBSCRIBE,
None)
def handle_unsubscribe(self, stanza):
self.on_unsubscribe(stanza)
def _remove_from_groups(self, item_to_remove, groups):
for group in groups:
try:
group_members = self.groups[group]
except KeyError:
continue
group_members.remove(item_to_remove)
if not group_members:
del self.groups[group]
self.on_group_removed(group)
async def _request_initial_roster(self):
iq = stanza.IQ(type_=structs.IQType.GET)
iq.payload = roster_xso.Query()
async with self.__roster_lock:
logger.debug("requesting initial roster")
if self.client.stream_features.has_feature(
roster_xso.RosterVersioningFeature):
logger.debug("requesting incremental updates (old ver = %s)",
self.version)
iq.payload.ver = self.version
response = await self.client.send(
iq,
timeout=self.client.negotiation_timeout.total_seconds()
)
if response is None:
logger.debug("roster will be updated incrementally")
self.on_initial_roster_received()
return True
self.version = response.ver
logger.debug("roster update received (new ver = %s)", self.version)
actual_jids = {item.jid for item in response.items}
known_jids = set(self.items.keys())
removed_jids = known_jids - actual_jids
logger.debug("jids dropped: %r", removed_jids)
for removed_jid in removed_jids:
old_item = self.items.pop(removed_jid)
self._remove_from_groups(old_item, old_item.groups)
self.on_entry_removed(old_item)
logger.debug("jids updated: %r", actual_jids - removed_jids)
for item in response.items:
self._update_entry(item)
self.on_initial_roster_received()
return True
def export_as_json(self):
"""
Export the whole roster as currently stored on the client side into a
JSON-compatible dictionary and return that dictionary.
"""
return {
"items": {
str(jid): item.export_as_json()
for jid, item in self.items.items()
},
"ver": self.version
}
def import_from_json(self, data):
"""
Replace the current roster with the :meth:`export_as_json`-compatible
dictionary in `data`.
No events are fired during this activity. After this method completes,
the whole roster contents are exchanged with the contents from `data`.
Also, no data is transferred to the server; this method is intended to
be used for roster versioning. See below (in the docs of
:class:`Service`).
"""
self.version = data.get("ver", None)
self.items.clear()
self.groups.clear()
for jid, data in data.get("items", {}).items():
jid = structs.JID.fromstr(jid)
item = Item(jid)
item.update_from_json(data)
self.items[jid] = item
for group in item.groups:
self.groups.setdefault(group, set()).add(item)
async def set_entry(self, jid, *,
name=_Sentinel,
add_to_groups=frozenset(),
remove_from_groups=frozenset(),
timeout=None):
"""
Set properties of a roster entry or add a new roster entry. The roster
entry is identified by its bare `jid`.
If an entry already exists, all values default to those stored in the
existing entry. For example, if no `name` is given, the current name of
the entry is re-used, if any.
If the entry does not exist, it will be created on the server side.
The `remove_from_groups` and `add_to_groups` arguments have to be based
on the locally cached state, as XMPP does not support sending
diffs. `remove_from_groups` takes precedence over `add_to_groups`.
`timeout` is the time in seconds to wait for a confirmation by the
server.
Note that the changes may not be visible immediately after his
coroutine returns in the :attr:`items` and :attr:`groups`
attributes. The :class:`Service` waits for the "official" roster push
from the server for updating the data structures and firing events, to
ensure that consistent state with other clients is achieved.
This may raise arbitrary :class:`.errors.XMPPError` exceptions if the
server replies with an error and also any kind of connection error if
the connection gets fatally terminated while waiting for a response.
"""
existing = self.items.get(jid, Item(jid))
post_groups = (existing.groups | add_to_groups) - remove_from_groups
post_name = existing.name
if name is not _Sentinel:
post_name = name
item = roster_xso.Item(
jid=jid,
name=post_name,
groups=[
roster_xso.Group(name=group_name)
for group_name in post_groups
])
await self.client.send(
stanza.IQ(
structs.IQType.SET,
payload=roster_xso.Query(items=[
item
])
),
timeout=timeout
)
async def remove_entry(self, jid, *, timeout=None):
"""
Request removal of the roster entry identified by the given bare
`jid`. If the entry currently has any subscription state, the server
will send the corresponding unsubscribing presence stanzas.
`timeout` is the maximum time in seconds to wait for a reply from the
server.
This may raise arbitrary :class:`.errors.XMPPError` exceptions if the
server replies with an error and also any kind of connection error if
the connection gets fatally terminated while waiting for a response.
"""
await self.client.send(
stanza.IQ(
structs.IQType.SET,
payload=roster_xso.Query(items=[
roster_xso.Item(
jid=jid,
subscription="remove"
)
])
),
timeout=timeout
)
def approve(self, peer_jid):
"""
(Pre-)approve a subscription request from `peer_jid`.
:param peer_jid: The peer to (pre-)approve.
This sends a ``"subscribed"`` presence to the peer; if the peer has
previously asked for a subscription, this will seal the deal and create
the subscription.
If the peer has not requested a subscription (yet), it is marked as
pre-approved by the server. A future subscription request by the peer
will then be confirmed by the server automatically.
.. note::
Pre-approval is an OPTIONAL feature in :rfc:`6121`. It is announced
as a stream feature.
"""
self.client.enqueue(
stanza.Presence(type_=structs.PresenceType.SUBSCRIBED,
to=peer_jid)
)
def subscribe(self, peer_jid):
"""
Request presence subscription with the given `peer_jid`.
This is deliberately not a coroutine; we don’t know whether the peer is
online (usually) and they may defer the confirmation very long, if they
confirm at all. Use :meth:`on_subscribed` to get notified when a peer
accepted a subscription request.
"""
self.client.enqueue(
stanza.Presence(type_=structs.PresenceType.SUBSCRIBE,
to=peer_jid)
)
def unsubscribe(self, peer_jid):
"""
Unsubscribe from the presence of the given `peer_jid`.
"""
self.client.enqueue(
stanza.Presence(type_=structs.PresenceType.UNSUBSCRIBE,
to=peer_jid)
)
aioxmpp/roster/xso.py 0000664 0000000 0000000 00000011552 14160146213 0015236 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import aioxmpp.stanza as stanza
import aioxmpp.nonza as nonza
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.rfc6121_roster = "jabber:iq:roster"
namespaces.rfc6121_roster_versioning = "urn:xmpp:features:rosterver"
class Group(xso.XSO):
"""
A group declaration for a contact in a roster.
.. attribute:: name
The name of the group.
"""
TAG = (namespaces.rfc6121_roster, "group")
name = xso.Text(default=None)
def __init__(self, *, name=None):
super().__init__()
self.name = name
class Item(xso.XSO):
"""
A contact item in a roster.
.. attribute:: jid
The bare :class:`~aioxmpp.JID` of the contact.
.. attribute:: name
The optional display name of the contact.
.. attribute:: groups
A :class:`~aioxmpp.xso.model.XSOList` of :class:`Group` instances which
describe the roster groups in which the contact is.
The following attributes represent the subscription status of the
contact. A client **must not** set these attributes when sending roster
items to the server. To change subscription status, use presence stanzas of
the respective type. The only exception is a :attr:`subscription` value of
``"remove"``, which is used to remove an entry from the roster.
.. attribute:: subscription
Primary subscription status, one of ``"none"`` (the default), ``"to"``,
``"from"`` and ``"both"``.
In addition, :attr:`subscription` can be set to ``"remove"`` to remove
an item from the roster during a roster set. Removing an entry from the
roster will also cancel any presence subscriptions from and to that
entries entity.
.. attribute:: approved
Whether the subscription has been pre-approved by the owning entity.
.. attribute:: ask
Subscription sub-states, one of ``"subscribe"`` and :data:`None`.
.. note::
Do not confuse this class with :class:`~aioxmpp.roster.Item`.
"""
TAG = (namespaces.rfc6121_roster, "item")
approved = xso.Attr(
"approved",
type_=xso.Bool(),
default=False,
)
ask = xso.Attr(
"ask",
validator=xso.RestrictToSet({
None,
"subscribe",
}),
validate=xso.ValidateMode.ALWAYS,
default=None,
)
jid = xso.Attr(
"jid",
type_=xso.JID(),
)
name = xso.Attr(
"name",
default=None,
)
subscription = xso.Attr(
"subscription",
validator=xso.RestrictToSet({
"none",
"to",
"from",
"both",
"remove",
}),
validate=xso.ValidateMode.ALWAYS,
default="none",
)
groups = xso.ChildList([Group])
def __init__(self, jid, *,
name=None,
groups=(),
subscription="none",
approved=False,
ask=None):
super().__init__()
if jid is not None:
self.jid = jid
self.name = name
self.groups.extend(groups)
self.subscription = subscription
self.approved = approved
self.ask = ask
@stanza.IQ.as_payload_class
class Query(xso.XSO):
"""
A query which fetches data from the roster or sends new items to the
roster.
.. attribute:: ver
The version of the roster, if any. See the RFC for the detailed
semantics.
.. attribute:: items
The items in the roster query.
"""
TAG = (namespaces.rfc6121_roster, "query")
ver = xso.Attr(
"ver",
default=None
)
items = xso.ChildList([Item])
def __init__(self, *, ver=None, items=()):
super().__init__()
self.ver = ver
self.items.extend(items)
@nonza.StreamFeatures.as_feature_class
class RosterVersioningFeature(xso.XSO):
"""
Roster versioning feature.
.. seealso::
:class:`aioxmpp.nonza.StreamFeatures`
"""
TAG = (namespaces.rfc6121_roster_versioning, "ver")
aioxmpp/rsm/ 0000775 0000000 0000000 00000000000 14160146213 0013332 5 ustar 00root root 0000000 0000000 aioxmpp/rsm/__init__.py 0000664 0000000 0000000 00000002552 14160146213 0015447 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.rsm` -- Result Set Management (:xep:`59`)
########################################################
This module provides the :mod:`XSO ` model for other parts of
:mod:`aioxmpp` and applications which wish to work with :xep:`59` (Result Set
Management).
.. versionadded:: 0.8
.. currentmodule:: aioxmpp.rsm.xso
.. module:: aioxmpp.rsm.xso
XSO
===
.. autoclass:: ResultSetMetadata()
.. autoclass:: After
.. autoclass:: Before
.. autoclass:: First
.. autoclass:: Last
"""
aioxmpp/rsm/xso.py 0000664 0000000 0000000 00000016772 14160146213 0014532 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import copy
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces, magicmethod
namespaces.xep0059_rsm = "http://jabber.org/protocol/rsm"
class _RangeLimitBase(xso.XSO):
value = xso.Text(default=None)
def __init__(self, value=None):
super().__init__()
self.value = value
class After(_RangeLimitBase):
"""
.. attribute:: value
Identifier of the element which serves as a range limit for the query.
"""
TAG = namespaces.xep0059_rsm, "after"
class Before(_RangeLimitBase):
"""
.. attribute:: value
Identifier of the element which serves as a range limit for the query.
"""
TAG = namespaces.xep0059_rsm, "before"
class First(_RangeLimitBase):
"""
.. attribute:: value
Identifier of the first element in the result set.
.. attribute:: index
Approximate index of the first element in the result set.
Can be used with :attr:`ResultSetMetadata.index` and
:meth:`ResultSetMetadata.fetch_page` to approximately re-retrieve the
page.
.. seealso::
:meth:`~ResultSetMetadata.fetch_page`
for hints on caveats and inaccuracies
"""
TAG = namespaces.xep0059_rsm, "first"
index = xso.Attr(
"index",
type_=xso.Integer(),
default=None,
)
class Last(_RangeLimitBase):
"""
.. attribute:: value
Identifier of the last element in the result set.
"""
TAG = namespaces.xep0059_rsm, "last"
class ResultSetMetadata(xso.XSO):
"""
Represent the result set or query metadata.
For requests, the following attributes are relevant:
.. attribute:: after
Either :data:`None` or a :class:`After` object.
Generally mutually exclusive with :attr:`index`.
.. attribute:: before
Either :data:`None` or a :class:`Before` object.
Generally mutually exclusive with :attr:`index`.
.. attribute:: index
The index of the first result to return, or :data:`None`.
Generally mutually exclusive with :attr:`after` and :attr:`before`.
.. attribute:: max
The maximum number of items to return or :data:`None`.
Setting :attr:`max` to zero will make the peer return a
:class:`ResultSetMetadata` with the total number of items in the
:attr:`count` field.
These methods are useful when constructing queries:
.. automethod:: fetch_page
.. automethod:: limit
.. automethod:: last_page
For responses, the following attributes are relevant:
.. attribute:: first
Either :data:`None` or a :class:`First` object.
.. attribute:: last
Either :data:`None` or a :class:`Last` object.
.. attribute:: count
Either :data:`None` or the number of elements in the result set.
If this is a response to a query with :attr:`max` set to zero, this is
the total number of elements in the queried data.
These methods are useful to construct a new request from a previous
response:
.. automethod:: next_page
.. automethod:: previous_page
"""
TAG = namespaces.xep0059_rsm, "set"
after = xso.Child([After])
before = xso.Child([Before])
first = xso.Child([First])
last = xso.Child([Last])
count = xso.ChildText(
(namespaces.xep0059_rsm, "count"),
type_=xso.Integer(),
default=None,
)
max_ = xso.ChildText(
(namespaces.xep0059_rsm, "max"),
type_=xso.Integer(),
default=None,
)
index = xso.ChildText(
(namespaces.xep0059_rsm, "index"),
type_=xso.Integer(),
default=None,
)
@classmethod
def fetch_page(cls, index, max_=None):
"""
Return a query set which requests a specific page.
:param index: Index of the first element of the page to fetch.
:type index: :class:`int`
:param max_: Maximum number of elements to fetch
:type max_: :class:`int` or :data:`None`
:rtype: :class:`ResultSetMetadata`
:return: A new request set up to request a page starting with the
element indexed by `index`.
.. note::
This way of retrieving items may be approximate. See :xep:`59` and
the embedding protocol for which RSM is used for specifics.
"""
result = cls()
result.index = index
result.max_ = max_
return result
@magicmethod
def limit(self, max_):
"""
Limit the result set to a given number of items.
:param max_: Maximum number of items to return.
:type max_: :class:`int` or :data:`None`
:rtype: :class:`ResultSetMetadata`
:return: A new request set up to request at most `max_` items.
This method can be called on the class and on objects. When called on
objects, it returns a copy of the object with :attr:`max_` set
accordingly. When called on the class, it creates a fresh object with
:attr:`max_` set accordingly.
"""
if isinstance(self, type):
result = self()
else:
result = copy.deepcopy(self)
result.max_ = max_
return result
def next_page(self, max_=None):
"""
Return a query set which requests the page after this response.
:param max_: Maximum number of items to return.
:type max_: :class:`int` or :data:`None`
:rtype: :class:`ResultSetMetadata`
:return: A new request set up to request the next page.
Must be called on a result set which has :attr:`last` set.
"""
result = type(self)()
result.after = After(self.last.value)
result.max_ = max_
return result
def previous_page(self, max_=None):
"""
Return a query set which requests the page before this response.
:param max_: Maximum number of items to return.
:type max_: :class:`int` or :data:`None`
:rtype: :class:`ResultSetMetadata`
:return: A new request set up to request the previous page.
Must be called on a result set which has :attr:`first` set.
"""
result = type(self)()
result.before = Before(self.first.value)
result.max_ = max_
return result
@classmethod
def last_page(self_or_cls, max_=None):
"""
Return a query set which requests the last page.
:param max_: Maximum number of items to return.
:type max_: :class:`int` or :data:`None`
:rtype: :class:`ResultSetMetadata`
:return: A new request set up to request the last page.
"""
result = self_or_cls()
result.before = Before()
result.max_ = max_
return result
aioxmpp/sasl.py 0000664 0000000 0000000 00000006266 14160146213 0014057 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: sasl.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.sasl` -- SASL helpers
####################################
This module is used to implement SASL in :mod:`aioxmpp.security_layer`. It
provides a state machine for use by the different SASL mechanisms and
implementations of some SASL mechanisms.
It provides an XMPP adaptor for :mod:`aiosasl`.
.. autoclass:: SASLXMPPInterface
The XSOs for SASL authentication can be found in :mod:`aioxmpp.nonza`.
"""
import asyncio
import logging
import aiosasl
from . import protocol, nonza
logger = logging.getLogger(__name__)
class SASLXMPPInterface(aiosasl.SASLInterface):
def __init__(self, xmlstream):
super().__init__()
self.xmlstream = xmlstream
self.timeout = None
async def _send_sasl_node_and_wait_for(self, node):
node = await protocol.send_and_wait_for(
self.xmlstream,
[node],
[
nonza.SASLChallenge,
nonza.SASLFailure,
nonza.SASLSuccess
],
timeout=self.timeout
)
state = node.TAG[1]
if state == "failure":
xmpp_error = node.condition[1]
text = node.text
raise aiosasl.SASLFailure(xmpp_error, text=text)
if hasattr(node, "payload"):
payload = node.payload
else:
payload = None
return state, payload
async def initiate(self, mechanism, payload=None):
with self.xmlstream.mute():
return await self._send_sasl_node_and_wait_for(
nonza.SASLAuth(mechanism=mechanism,
payload=payload))
async def respond(self, payload):
with self.xmlstream.mute():
return await self._send_sasl_node_and_wait_for(
nonza.SASLResponse(payload=payload)
)
async def abort(self):
try:
next_state, payload = await self._send_sasl_node_and_wait_for(
nonza.SASLAbort()
)
except aiosasl.SASLFailure as err:
self._state = "failure"
if err.opaque_error != "aborted":
raise
return "failure", None
else:
raise aiosasl.SASLFailure(
"aborted",
text="unexpected non-failure after abort: "
"{}".format(self._state)
)
aioxmpp/security_layer.py 0000664 0000000 0000000 00000144416 14160146213 0016160 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: security_layer.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.security_layer` --- Implementations to negotiate stream security
####################################################################################
This module provides different implementations of the security layer
(TLS+SASL).
These are coupled, as different SASL features might need different TLS features
(such as channel binding or client cert authentication). The preferred method
to construct a :class:`SecurityLayer` is using the :func:`make` function.
:class:`SecurityLayer` objects are needed to establish an XMPP connection,
for example using :class:`aioxmpp.Client`.
.. autofunction:: make
.. autoclass:: PinType
.. autofunction:: tls_with_password_based_authentication(password_provider, [ssl_context_factory], [max_auth_attempts=3])
.. autoclass:: SecurityLayer(ssl_context_factory, certificate_verifier_factory, tls_required, sasl_providers)
.. autofunction:: negotiate_sasl
Certificate verifiers
=====================
To verify the peer certificate provided by the server, different
:class:`CertificateVerifier`\ s are available:
.. autoclass:: PKIXCertificateVerifier
To implement your own verifiers, see the documentation at the base class for
certificate verifiers:
.. autoclass:: CertificateVerifier
Certificate and key pinning
---------------------------
Often in the XMPP world, we need certificate or public key pinning, as most
XMPP servers do not have certificates trusted by the usual certificate
stores. This module also provide certificate verifiers which can be used for
that purpose, as well as stores for saving the pinned information.
.. autoclass:: PinningPKIXCertificateVerifier
.. autoclass:: CertificatePinStore
.. autoclass:: PublicKeyPinStore
Base classes
^^^^^^^^^^^^
For future expansion or customization, the base classes of the above utilities
can be subclassed and extended:
.. autoclass:: HookablePKIXCertificateVerifier
.. autoclass:: AbstractPinStore
.. _sasl providers:
SASL providers
==============
As elements of the `sasl_providers` argument to :class:`SecurityLayer`,
instances of the following classes can be used:
.. autoclass:: PasswordSASLProvider
.. autoclass:: AnonymousSASLProvider
.. note::
Patches welcome for additional :class:`SASLProvider` implementations.
Abstract base classes
=====================
For implementation of custom SASL providers, the following base class can be
used:
.. autoclass:: SASLProvider
Deprecated functionality
========================
In pre-0.6 code, you might find use of the following things:
.. autofunction:: security_layer
.. autoclass:: STARTTLSProvider
""" # NOQA: E501
import abc
import asyncio
import base64
import collections
import enum
import logging
import ssl
import pyasn1
import pyasn1.codec.der.decoder
import pyasn1.codec.der.encoder
import pyasn1_modules.rfc2459
import OpenSSL.SSL
import aiosasl
from . import errors, sasl, nonza, xso, protocol
from .utils import namespaces
logger = logging.getLogger(__name__)
def extract_python_dict_from_x509(x509):
"""
Extract a python dictionary similar to the return value of
:meth:`ssl.SSLSocket.getpeercert` from the given
:class:`OpenSSL.crypto.X509` `x509` object.
Note that by far not all attributes are included; only those required to
use :func:`ssl.match_hostname` are extracted and put in the result.
In the future, more attributes may be added.
"""
result = {
"subject": (
(("commonName", x509.get_subject().commonName),),
)
}
for ext_idx in range(x509.get_extension_count()):
ext = x509.get_extension(ext_idx)
sn = ext.get_short_name()
if sn != b"subjectAltName":
continue
data = pyasn1.codec.der.decoder.decode(
ext.get_data(),
asn1Spec=pyasn1_modules.rfc2459.SubjectAltName())[0]
for name in data:
dNSName = name.getComponentByPosition(2)
if dNSName is None:
continue
if hasattr(dNSName, "isValue") and not dNSName.isValue:
continue
result.setdefault("subjectAltName", []).append(
("DNS", str(dNSName))
)
return result
def extract_blob(x509):
"""
Extract an ASN.1 blob from the given :class:`OpenSSL.crypto.X509`
certificate. Return the resulting :class:`bytes` object.
"""
return OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_ASN1,
x509)
def blob_to_pyasn1(blob):
"""
Convert an ASN.1 encoded certificate (such as obtained from
:func:`extract_blob`) to a :mod:`pyasn1` structure and return the result.
"""
return pyasn1.codec.der.decoder.decode(
blob,
asn1Spec=pyasn1_modules.rfc2459.Certificate()
)[0]
def extract_pk_blob_from_pyasn1(pyasn1_struct):
"""
Extract an ASN.1 encoded public key blob from the given :mod:`pyasn1`
structure (which must represent a certificate).
"""
pk = pyasn1_struct.getComponentByName(
"tbsCertificate"
).getComponentByName(
"subjectPublicKeyInfo"
)
return pyasn1.codec.der.encoder.encode(pk)
def check_x509_hostname(x509, hostname):
"""
Check whether the given :class:`OpenSSL.crypto.X509` certificate `x509`
matches the given `hostname`.
Return :data:`True` if the name matches and :data:`False` otherwise. This
uses :func:`ssl.match_hostname` and :func:`extract_python_dict_from_x509`.
"""
cert_structure = extract_python_dict_from_x509(x509)
try:
ssl.match_hostname(cert_structure, hostname)
except ssl.CertificateError:
return False
return True
class CertificateVerifier(metaclass=abc.ABCMeta):
"""
A certificate verifier hooks into the two mechanisms provided by
:class:`aioopenssl.STARTTLSTransport` for certificate verification.
On the one hand, the verify callback provided by
:class:`OpenSSL.SSL.Context` is used and forwarded to
:meth:`verify_callback`. On the other hand, the post handshake coroutine is
set to :meth:`post_handshake`. See the documentation of
:class:`aioopenssl.STARTTLSTransport` for the semantics of that
coroutine.
In addition to these two hooks into the TLS handshake, a third coroutine
which is called before STARTTLS is intiiated is provided.
This baseclass provides a bit of boilerplate.
"""
async def pre_handshake(self, metadata, domain, host, port):
pass
def setup_context(self, ctx, transport):
self.transport = transport
ctx.set_verify(OpenSSL.SSL.VERIFY_PEER, self.verify_callback)
@abc.abstractmethod
def verify_callback(self, conn, x509, errno, errdepth, returncode):
return returncode
@abc.abstractmethod
async def post_handshake(self, transport):
pass
class _NullVerifier(CertificateVerifier):
def setup_context(self, ctx, transport):
self.transport = transport
ctx.set_verify(OpenSSL.SSL.VERIFY_NONE, self.verify_callback)
def verify_callback(self, *args):
return True
async def post_handshake(self, transport):
pass
class PKIXCertificateVerifier(CertificateVerifier):
"""
This verifier enables the default PKIX based verification of certificates
as implemented by OpenSSL.
The :meth:`verify_callback` checks that the certificate subject matches the
domain name of the JID of the connection.
"""
def verify_callback(self, ctx, x509, errno, errdepth, returncode):
logger.info("verifying certificate (preverify=%s)", returncode)
if not returncode:
logger.warning("certificate verification failed (by OpenSSL)")
return returncode
if errdepth == 0:
hostname = self.transport.get_extra_info("server_hostname")
if not check_x509_hostname(
x509,
hostname):
logger.warning("certificate hostname mismatch "
"(doesn’t match for %r)",
hostname)
return False
return returncode
def setup_context(self, ctx, transport):
super().setup_context(ctx, transport)
ctx.set_default_verify_paths()
async def post_handshake(self, transport):
pass
class HookablePKIXCertificateVerifier(CertificateVerifier):
"""
This PKIX-based verifier has several hooks which allow overriding of the
checking process, for example to implement key or certificate pinning.
It provides three callbacks:
* `quick_check` is a synchronous callback (and must be a plain function)
which is called from :meth:`verify_callback`. It is only called if the
certificate fails full PKIX verification, and only for certain cases. For
example, expired certificates do not get a second chance and are rejected
immediately.
It is called with the leaf certificate as its only argument. It must
return :data:`True` if the certificate is known good and should pass the
verification. If the certificate is known bad and should fail the
verification immediately, it must return :data:`False`.
If the certificate is unknown and the check should be deferred to the
`post_handshake_deferred_failure` callback, :data:`None` must be
returned.
Passing :data:`None` to `quick_check` is the same as if a callable passed
to `quick_check` would return :data:`None` always (i.e. the decision is
deferred).
* `post_handshake_deferred_failure` must be a coroutine. It is called after
the handshake is done but before the STARTTLS negotiation has finished
and allows the application to take more time to decide on a certificate
and possibly request user input.
The coroutine receives the verifier instance as its argument and can make
use of all the verification attributes to present the user with a
sensible choice.
If `post_handshake_deferred_failure` is :data:`None`, the result is
identical to returning :data:`False` from the callback.
* `post_handshake_success` is only called if the certificate has passed the
verification (either because it flawlessly passed by OpenSSL or the
`quick_check` callback returned :data:`True`).
You may pass :data:`None` to this argument to disable the callback
without any further side effects.
The following attributes are available when the post handshake callbacks
are called:
.. attribute:: recorded_errors
This is a :class:`set` with tuples consisting of a
:class:`OpenSSL.crypto.X509` instance, an OpenSSL error number and the
depth of the certificate in the verification chain (0 is the leaf
certificate).
It is a collection of all errors which were passed into
:meth:`verify_callback` by OpenSSL.
.. attribute:: hostname_matches
This is :data:`True` if the host name in the leaf certificate matches
the domain part of the JID for which we are connecting (i.e. the usual
server name check).
.. attribute:: leaf_x509
The :class:`OpenSSL.crypto.X509` object which represents the leaf
certificate.
"""
# these are the errors for which we allow pinning the certificate
_DEFERRABLE_ERRORS = {
(20, None), # issuer certificate not available locally
(19, None), # self-signed cert in chain
(18, 0), # depth-zero self-signed cert
(27, 0), # cert untrusted
(21, 0), # leaf certificate not signed ...
}
def __init__(self,
quick_check,
post_handshake_deferred_failure,
post_handshake_success):
self._quick_check = quick_check
self._post_handshake_success = post_handshake_success
self._post_handshake_deferred_failure = post_handshake_deferred_failure
self.recorded_errors = set()
self.deferred = True
self.hostname_matches = False
self.leaf_x509 = None
def verify_callback(self, ctx, x509, errno, depth, preverify):
if errno != 0 and errno != 21:
self.recorded_errors.add((x509, errno, depth))
return True
if depth == 0:
if errno != 0:
logger.debug(
"unsigned certificate; this is odd to say the least"
)
self.recorded_errors.add((x509, errno, depth))
hostname = self.transport.get_extra_info("server_hostname")
self.hostname_matches = check_x509_hostname(x509, hostname)
self.leaf_x509 = x509
return self.verify_recorded(x509, self.recorded_errors)
return True
def verify_recorded(self, leaf_x509, records):
self.deferred = False
if not records:
return True
hostname = self.transport.get_extra_info("server_hostname")
self.hostname_matches = check_x509_hostname(leaf_x509, hostname)
for x509, errno, depth in records:
if ((errno, depth) not in self._DEFERRABLE_ERRORS and
(errno, None) not in self._DEFERRABLE_ERRORS):
logger.warning("non-deferrable certificate error: "
"depth=%d, errno=%d",
depth, errno)
return False
if self._quick_check is not None:
result = self._quick_check(leaf_x509)
logger.debug("certificate quick-check returned %r", result)
else:
result = None
logger.debug("no certificate quick-check")
if result is None:
self.deferred = True
return result is not False
async def post_handshake(self, transport):
if self.deferred:
if self._post_handshake_deferred_failure is not None:
result = await self._post_handshake_deferred_failure(self)
else:
result = False
if not result:
raise errors.TLSFailure("certificate verification failed")
else:
if self._post_handshake_success is not None:
await self._post_handshake_success()
class AbstractPinStore(metaclass=abc.ABCMeta):
"""
This is the abstract base class for both :class:`PublicKeyPinStore` and
:class:`CerificatePinStore`. The interface for both types of pinning is
identical; the only difference is in which information is stored.
.. automethod:: pin
.. automethod:: query
.. automethod:: get_pinned_for_host
.. automethod:: export_to_json
.. automethod:: import_from_json
For subclasses:
.. automethod:: _encode_key
.. automethod:: _decode_key
.. automethod:: _x509_key
"""
def __init__(self):
self._storage = {}
@abc.abstractmethod
def _x509_key(self, key):
"""
Return a hashable value which identifies the given `x509` certificate
for the purposes of the key store. See the implementations
:meth:`PublicKeyPinStore._x509_key` and
:meth:`CertificatePinStore._x509_key` for details on what is stored for
the respective subclasses.
This method is abstract and must be implemented in subclasses.
"""
def _encode_key(self, key):
"""
Encode the `key` (which has previously been obtained from
:meth:`_x509_key`) into a string which is both JSON compatible and can
be used as XML text (which means that it must not contain control
characters, for example).
The method is called by :meth:`export_to_json`. The default
implementation returns `key`.
"""
return key
def _decode_key(self, obj):
"""
Decode the `obj` into a key which is compatible to the values returned
by :meth:`_x509_key`.
The method is called by :meth:`import_from_json`. The default
implementation returns `obj`.
"""
return obj
def pin(self, hostname, x509):
"""
Pin an :class:`OpenSSL.crypto.X509` object `x509` for use with the
given `hostname`. Which information exactly is used to identify the
certificate depends :meth:`_x509_key`.
"""
key = self._x509_key(x509)
self._storage.setdefault(hostname, set()).add(key)
def query(self, hostname, x509):
"""
Return true if the given :class:`OpenSSL.crypto.X509` object `x509` has
previously been pinned for use with the given `hostname` and
:data:`None` otherwise.
Returning :data:`None` allows this method to be used with
:class:`PinningPKIXCertificateVerifier`.
"""
key = self._x509_key(x509)
try:
pins = self._storage[hostname]
except KeyError:
return None
if key in pins:
return True
return None
def get_pinned_for_host(self, hostname):
"""
Return the set of hashable values which are used to identify the X.509
certificates which are accepted for the given `hostname`.
If no values have previously been pinned, this returns the empty set.
"""
try:
return frozenset(self._storage[hostname])
except KeyError:
return frozenset()
def export_to_json(self):
"""
Return a JSON dictionary which contains all the pins stored in this
store.
"""
return {
hostname: sorted(self._encode_key(key) for key in pins)
for hostname, pins in self._storage.items()
}
def import_from_json(self, data, *, override=False):
"""
Import a JSON dictionary which must have the same format as exported by
:meth:`export`.
If *override* is true, the existing data in the pin store will be
overridden with the data from `data`. Otherwise, the `data` will be
merged into the store.
"""
if override:
self._storage = {
hostname: set(self._decode_key(key) for key in pins)
for hostname, pins in data.items()
}
return
for hostname, pins in data.items():
existing_pins = self._storage.setdefault(hostname, set())
existing_pins.update(self._decode_key(key) for key in pins)
class PublicKeyPinStore(AbstractPinStore):
"""
This pin store stores the public keys of the X.509 objects which are passed
to its :meth:`pin` method.
"""
def _x509_key(self, x509):
blob = extract_blob(x509)
pyasn1_struct = blob_to_pyasn1(blob)
return extract_pk_blob_from_pyasn1(pyasn1_struct)
def _encode_key(self, key):
return base64.b64encode(key).decode("ascii")
def _decode_key(self, obj):
return base64.b64decode(obj.encode("ascii"))
class CertificatePinStore(AbstractPinStore):
"""
This pin store stores the whole certificates which are passed to its
:meth:`pin` method.
"""
def _x509_key(self, x509):
return extract_blob(x509)
def _encode_key(self, key):
return base64.b64encode(key).decode("ascii")
def _decode_key(self, obj):
return base64.b64decode(obj.encode("ascii"))
class PinningPKIXCertificateVerifier(HookablePKIXCertificateVerifier):
"""
The :class:`PinningPKIXCertificateVerifier` is a subclass of the
:class:`HookablePKIXCertificateVerifier` which uses the hooks to implement
certificate or public key pinning.
It does not store the pins itself. Instead, the user must pass a callable
to the `query_pin` argument. That callable will be called with two
arguments: the `servername` and the `x509`. The `x509` is a
:class:`OpenSSL.crypto.X509` instance, which is the leaf certificate which
attempts to identify the host. The `servername` is the name of the server
we try to connect to (the identifying name, like the domain part of the
JID). The callable must return :data:`True` (to accept the certificate),
:data:`False` (to reject the certificate) or :data:`None` (to defer the
decision to the `post_handshake_deferred_failure` callback). `query_pin`
must not block; if it needs to do blocking operations, it should defer.
The other two arguments are coroutines with semantics identical to those of
the same-named arguments in :class:`HookablePKIXCertificateVerifier`.
.. seealso::
:meth:`AbstractPinStore.query` is a method which can be passed as
`query_pin` callback.
"""
def __init__(self,
query_pin,
post_handshake_deferred_failure,
post_handshake_success=None):
super().__init__(
self._quick_check_query_pin,
post_handshake_deferred_failure,
post_handshake_success
)
self._query_pin = query_pin
def _quick_check_query_pin(self, leaf_x509):
hostname = self.transport.get_extra_info("server_hostname")
is_pinned = self._query_pin(hostname, leaf_x509)
if not is_pinned:
logger.debug(
"certificate for %r does not appear in pin store",
hostname,
)
return is_pinned
class ErrorRecordingVerifier(CertificateVerifier):
def __init__(self):
super().__init__()
self._errors = []
def _record_verify_info(self, x509, errno, depth):
self._errors.append((x509, errno, depth))
def verify_callback(self, x509, errno, depth, returncode):
self._record_verify_info(x509, errno, depth)
return True
async def post_handshake(self, transport):
if self._errors:
raise errors.TLSFailure(
"Peer certificate verification failure: {}".format(
", ".join(map(str, self._errors))))
class SASLMechanism(xso.XSO):
TAG = (namespaces.sasl, "mechanism")
name = xso.Text()
def __init__(self, name=None):
super().__init__()
self.name = name
@nonza.StreamFeatures.as_feature_class
class SASLMechanisms(xso.XSO):
TAG = (namespaces.sasl, "mechanisms")
mechanisms = xso.ChildList([SASLMechanism])
def get_mechanism_list(self):
return [
mechanism.name
for mechanism in self.mechanisms
]
class SASLProvider:
"""
Base class to implement a SASL provider.
SASL providers are used in :class:`SecurityLayer` to authenticate the local
user with a service. The credentials required depend on the specific SASL
provider, and it is recommended to acquire means to get these credentials
via constructor parameters (see for example :class:`PasswordSASLProvider`).
The following methods must be implemented by subclasses:
.. automethod:: execute
The following methods are intended to be re-used by subclasses:
.. automethod:: _execute
.. automethod:: _find_supported
"""
def _find_supported(self, features, mechanism_classes):
"""
Find the first mechanism class which supports a mechanism announced in
the given stream features.
:param features: Current XMPP stream features
:type features: :class:`~.nonza.StreamFeatures`
:param mechanism_classes: SASL mechanism classes to use
:type mechanism_classes: iterable of :class:`SASLMechanism`
sub\\ *classes*
:raises aioxmpp.errors.SASLUnavailable: if the peer does not announce
SASL support
:return: the :class:`SASLMechanism` subclass to use and a token
:rtype: pair
Return a supported SASL mechanism class, by looking the given
stream features `features`.
If no matching mechanism is found, ``(None, None)`` is
returned. Otherwise, a pair consisting of the mechanism class and the
value returned by the respective
:meth:`~.sasl.SASLMechanism.any_supported` method is returned. The
latter is an opaque token which must be passed to the `token` argument
of :meth:`_execute` or :meth:`aiosasl.SASLMechanism.authenticate`.
"""
try:
mechanisms = features[SASLMechanisms]
except KeyError:
logger.error("No sasl mechanisms: %r", list(features))
raise errors.SASLUnavailable(
"Remote side does not support SASL") from None
remote_mechanism_list = mechanisms.get_mechanism_list()
for our_mechanism in mechanism_classes:
token = our_mechanism.any_supported(remote_mechanism_list)
if token is not None:
return our_mechanism, token
return None, None
AUTHENTICATION_FAILURES = {
"credentials-expired",
"account-disabled",
"invalid-authzid",
"not-authorized",
"temporary-auth-failure",
}
MECHANISM_REJECTED_FAILURES = {
"invalid-mechanism",
"mechanism-too-weak",
"encryption-required",
}
async def _execute(self, intf, mechanism, token):
"""
Execute a SASL authentication process.
:param intf: SASL interface to use
:type intf: :class:`~.sasl.SASLXMPPInterface`
:param mechanism: SASL mechanism to use
:type mechanism: :class:`aiosasl.SASLMechanism`
:param token: The opaque token argument for the mechanism
:type token: not :data:`None`
:raises aiosasl.AuthenticationFailure: if authentication failed due to
bad credentials
:raises aiosasl.SASLFailure: on other SASL error conditions (such as
protocol violations)
:return: true if authentication succeeded, false if the mechanism has
to be disabled
:rtype: :class:`bool`
This executes the SASL authentication process. The more specific
exceptions are generated by inspecting the
:attr:`aiosasl.SASLFailure.opaque_error` on exceptinos raised from the
:class:`~.sasl.SASLXMPPInterface`. Other :class:`aiosasl.SASLFailure`
exceptions are re-raised without modification.
"""
sm = aiosasl.SASLStateMachine(intf)
try:
await mechanism.authenticate(sm, token)
return True
except aiosasl.SASLFailure as err:
if err.opaque_error in self.AUTHENTICATION_FAILURES:
raise aiosasl.AuthenticationFailure(
opaque_error=err.opaque_error,
text=err.text)
elif err.opaque_error in self.MECHANISM_REJECTED_FAILURES:
return False
raise
@abc.abstractmethod
async def execute(self, client_jid, features, xmlstream, tls_transport):
"""
Perform SASL negotiation.
:param client_jid: The JID the client attempts to authenticate for
:type client_jid: :class:`aioxmpp.JID`
:param features: Current stream features nonza
:type features: :class:`~.nonza.StreamFeatures`
:param xmlstream: The XML stream to authenticate over
:type xmlstream: :class:`~.protocol.XMLStream`
:param tls_transport: The TLS transport or :data:`None` if no TLS has
been negotiated
:type tls_transport: :class:`asyncio.Transport` or :data:`None`
:raise aiosasl.AuthenticationFailure: if authentication failed due to
bad credentials
:raise aiosasl.SASLFailure: on other SASL-related errors
:return: true if the negotiation was successful, false if no common
mechanisms could be found or all mechanisms failed for reasons
unrelated to the credentials themselves.
:rtype: :class:`bool`
The implementation depends on the specific :class:`SASLProvider`
subclass in use.
This coroutine returns :data:`True` if the negotiation was
successful. If no common mechanisms could be found, :data:`False` is
returned. This is useful to chain several SASL providers (e.g. a
provider supporting ``EXTERNAL`` in front of password-based providers).
Any other error case, such as no SASL support on the remote side or
authentication failure results in an :class:`aiosasl.SASLFailure`
exception to be raised.
"""
class PasswordSASLProvider(SASLProvider):
"""
Perform password-based SASL authentication.
:param password_provider: A coroutine function returning the password to
authenticate with.
:type password_provider: coroutine function
:param max_auth_attempts: Maximum number of authentication attempts with a
single mechanism.
:type max_auth_attempts: positive :class:`int`
`password_provider` must be a coroutine taking two arguments, a JID and an
integer number. The first argument is the JID which is trying to
authenticate and the second argument is the number of the authentication
attempt, starting at 0. On each attempt, the number is increased, up to
`max_auth_attempts`\\ -1. If the coroutine returns :data:`None`, the
authentication process is aborted. If the number of attempts are exceeded,
the authentication process is also aborted. In both cases, an
:class:`aiosasl.AuthenticationFailure` error will be raised.
The SASL mechanisms used depend on whether TLS has been negotiated
successfully before. In any case, :class:`aiosasl.SCRAM` is used. If TLS
has been negotiated, :class:`aiosasl.PLAIN` is also supported.
.. seealso::
:class:`SASLProvider`
for the public interface of this class.
"""
def __init__(self, password_provider, *,
max_auth_attempts=3, **kwargs):
super().__init__(**kwargs)
self._password_provider = password_provider
self._max_auth_attempts = max_auth_attempts
async def execute(self, client_jid, features, xmlstream, tls_transport):
client_jid = client_jid.bare()
password_signalled_abort = False
nattempt = 0
cached_credentials = None
async def credential_provider():
nonlocal password_signalled_abort, nattempt, cached_credentials
if cached_credentials is not None:
return client_jid.localpart, cached_credentials
password = await self._password_provider(client_jid, nattempt)
if password is None:
password_signalled_abort = True
raise aiosasl.AuthenticationFailure(
"user intervention",
text="authentication aborted by user")
cached_credentials = password
return client_jid.localpart, password
classes = [
aiosasl.SCRAM
]
if tls_transport is not None:
classes.append(aiosasl.PLAIN)
intf = sasl.SASLXMPPInterface(xmlstream)
while classes:
# go over all mechanisms available. some errors disable a mechanism
# (like encryption-required or mechansim-too-weak)
mechanism_class, token = self._find_supported(features, classes)
if mechanism_class is None:
return False
mechanism = mechanism_class(credential_provider)
last_auth_error = None
for nattempt in range(self._max_auth_attempts):
try:
mechanism_worked = await self._execute(
intf, mechanism, token)
except (ValueError, aiosasl.AuthenticationFailure) as err:
if password_signalled_abort:
# immediately re-raise
raise
last_auth_error = err
# allow the user to re-try
cached_credentials = None
continue
else:
break
else:
raise last_auth_error
if mechanism_worked:
return True
classes.remove(mechanism_class)
return False
class AnonymousSASLProvider(SASLProvider):
"""
Perform the ``ANONYMOUS`` SASL mechanism (:rfc:`4505`).
:param token: The trace token for the ``ANONYMOUS`` mechanism
:type token: :class:`str`
`token` SHOULD be the empty string in the XMPP context (see :xep:`175`).
.. seealso::
:class:`SASLProvider`
for the public interface of this class.
.. warning::
Take the security and privacy considerations from :rfc:`4505` (which
specifies the ANONYMOUS SASL mechanism) and :xep:`175` (which discusses
the ANONYMOUS SASL mechanism in the XMPP context) into account before
using this provider.
.. note::
This class requires :class:`aiosasl.ANONYMOUS`, which is available with
:mod:`aiosasl` 0.3 or newer. If :class:`aiosasl.ANONYMOUS` is not
provided, this class is replaced with :data:`None`.
.. versionadded:: 0.8
"""
def __init__(self, token):
super().__init__()
self._token = token
async def execute(self, client_jid, features, xmlstream, tls_transport):
mechanism_class, token = self._find_supported(
features,
[aiosasl.ANONYMOUS]
)
if mechanism_class is None:
return False
intf = sasl.SASLXMPPInterface(xmlstream)
mechanism = aiosasl.ANONYMOUS(self._token)
return await self._execute(
intf,
mechanism,
token,
)
if not hasattr(aiosasl, "ANONYMOUS"):
AnonymousSASLProvider = None # NOQA
class SecurityLayer(collections.namedtuple(
"SecurityLayer",
[
"ssl_context_factory",
"certificate_verifier_factory",
"tls_required",
"sasl_providers",
])):
"""
A security layer defines the security properties used for an XML stream.
This includes TLS settings and SASL providers. The arguments are used to
initialise the attributes of the same name.
:class:`SecurityLayer` instances are required to construct a
:class:`aioxmpp.Client`.
.. versionadded:: 0.6
.. seealso::
:func:`make`
A powerful function which can be used to create a configured
:class:`SecurityLayer` instance.
.. attribute:: ssl_context_factory
This is a callable returning a :class:`OpenSSL.SSL.Context` instance
which is to be used for any SSL operations for the connection.
The :class:`OpenSSL.SSL.Context` instances should not be reused between
connection attempts, as the certificate verifiers may set options which
cannot be disabled anymore.
.. attribute:: certificate_verifier_factory
This is a callable which returns a fresh
:class:`CertificateVerifier` on each call (it must be a fresh instance
since :class:`CertificateVerifier` objects are allowed to keep state and
:class:`SecurityLayer` objects are reusable between connection
attempts).
.. attribute:: tls_required
A boolean which indicates whether TLS is required. If it is set to true,
connectors (see :mod:`aioxmpp.connector`) will abort the connection if
TLS (or something equivalent) is not available on the transport.
.. note::
Disabling this makes your application vulnerable to STARTTLS
stripping attacks.
.. attribute:: sasl_providers
A sequence of :class:`SASLProvider` instances. As SASL providers are
stateless, it is not necessary to create new providers for each
connection.
"""
def default_verify_callback(conn, x509, errno, errdepth, returncode):
return errno == 0
def default_ssl_context():
"""
Return a sensibly configured :class:`OpenSSL.SSL.Context` context.
The context has SSLv2 and SSLv3 disabled, and supports TLS 1.0+ (depending
on the version of the SSL library).
Tries to negotiate an XMPP c2s connection via ALPN (:rfc:`7301`).
"""
ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2 | OpenSSL.SSL.OP_NO_SSLv3)
ctx.set_verify(OpenSSL.SSL.VERIFY_PEER, default_verify_callback)
return ctx
async def negotiate_sasl(transport, xmlstream,
sasl_providers,
negotiation_timeout,
jid, features):
"""
Perform SASL authentication on the given :class:`.protocol.XMLStream`
`stream`. `transport` must be the :class:`asyncio.Transport` over which the
`stream` runs. It is used to detect whether TLS is used and may be required
by some SASL mechanisms.
`sasl_providers` must be an iterable of :class:`SASLProvider` objects. They
will be tried in iteration order to authenticate against the server. If one
of the `sasl_providers` fails with a :class:`aiosasl.AuthenticationFailure`
exception, the other providers are still tried; only if all providers fail,
the last :class:`aiosasl.AuthenticationFailure` exception is re-raised.
If no mechanism was able to authenticate but not due to authentication
failures (other failures include no matching mechanism on the server side),
:class:`aiosasl.SASLUnavailable` is raised.
Return the :class:`.nonza.StreamFeatures` obtained after resetting the
stream after successful SASL authentication.
.. versionadded:: 0.6
.. deprecated:: 0.10
The `negotiation_timeout` argument is ignored. The timeout is
controlled using the :attr:`~.XMLStream.deadtime_hard_limit` timeout
of the stream.
The argument will be removed in version 1.0. To prepare for this,
please pass `jid` and `features` as keyword arguments.
"""
if not transport.get_extra_info("sslcontext"):
transport = None
last_auth_error = None
for sasl_provider in sasl_providers:
try:
result = await sasl_provider.execute(
jid, features, xmlstream, transport)
except ValueError as err:
raise errors.StreamNegotiationFailure(
"invalid credentials: {}".format(err)
) from err
except aiosasl.AuthenticationFailure as err:
last_auth_error = err
continue
if result:
features = await protocol.reset_stream_and_get_features(
xmlstream
)
break
else:
if last_auth_error:
raise last_auth_error
else:
raise errors.SASLUnavailable("No common mechanisms")
return features
class STARTTLSProvider:
"""
.. deprecated:: 0.6
Do **not** use this. This is a shim class which provides
backward-compatibility for versions older than 0.6.
"""
def __init__(self, ssl_context_factory,
certificate_verifier_factory=PKIXCertificateVerifier,
*,
require_starttls=True):
self.ssl_context_factory = ssl_context_factory
self.certificate_verifier_factory = certificate_verifier_factory
self.tls_required = require_starttls
def security_layer(tls_provider, sasl_providers):
"""
.. deprecated:: 0.6
Replaced by :class:`SecurityLayer`.
Return a configured :class:`SecurityLayer`. `tls_provider` must be a
:class:`STARTTLSProvider`.
The return value can be passed to the constructor of
:class:`~.node.Client`.
Some very basic checking on the input is also performed.
"""
sasl_providers = tuple(sasl_providers)
if not sasl_providers:
raise ValueError("At least one SASL provider must be given.")
for sasl_provider in sasl_providers:
sasl_provider.execute # check that sasl_provider has execute method
result = SecurityLayer(
tls_provider.ssl_context_factory,
tls_provider.certificate_verifier_factory,
tls_provider.tls_required,
sasl_providers
)
return result
def tls_with_password_based_authentication(
password_provider,
ssl_context_factory=default_ssl_context,
max_auth_attempts=3,
certificate_verifier_factory=PKIXCertificateVerifier):
"""
Produce a commonly used :class:`SecurityLayer`, which uses TLS and
password-based SASL authentication. If `ssl_context_factory` is not
provided, an SSL context with TLSv1+ is used.
`password_provider` must be a coroutine which is called with the jid
as first and the number of attempt as second argument. It must return the
password to us, or :data:`None` to abort.
Return a :class:`SecurityLayer` instance.
.. deprecated:: 0.7
Use :func:`make` instead.
"""
tls_kwargs = {}
if certificate_verifier_factory is not None:
tls_kwargs["certificate_verifier_factory"] = \
certificate_verifier_factory
return SecurityLayer(
ssl_context_factory,
certificate_verifier_factory,
True,
(
PasswordSASLProvider(
password_provider,
max_auth_attempts=max_auth_attempts),
)
)
class PinType(enum.Enum):
"""
Enumeration to control which pinning is used by :meth:`make`.
.. attribute:: PUBLIC_KEY
Public keys are stored in the pin store. :class:`PublicKeyPinStore` is
used.
.. attribute:: CERTIFICATE
Whole certificates are stored in the pin store.
:class:`CertificatePinStore` is used.
"""
PUBLIC_KEY = 0
CERTIFICATE = 1
def make(
password_provider,
*,
pin_store=None,
pin_type=PinType.PUBLIC_KEY,
post_handshake_deferred_failure=None,
anonymous=False,
ssl_context_factory=default_ssl_context,
no_verify=False):
"""
Construct a :class:`SecurityLayer`. Depending on the arguments passed,
different features are enabled or disabled.
.. warning::
When using any argument except `password_provider`, be sure to read
its documentation below the following overview **carefully**. Many
arguments can be used to shoot yourself in the foot easily, while
violating all security expectations.
Args:
password_provider (:class:`str` or coroutine function):
Password source to authenticate with.
Keyword Args:
pin_store (:class:`dict` or :class:`AbstractPinStore`):
Enable use of certificate/public key pinning. `pin_type` controls
the type of store used when a dict is passed instead of a pin store
object.
pin_type (:class:`~aioxmpp.security_layer.PinType`):
Type of pin store to create when `pin_store` is a dict.
post_handshake_deferred_failure (coroutine function):
Coroutine callback to invoke when using certificate pinning and the
verification of the certificate was not possible using either PKIX
or the pin store.
anonymous (:class:`str`, :data:`None` or :data:`False`):
trace token for SASL ANONYMOUS (:rfc:`4505`); passing a
non-:data:`False` value enables ANONYMOUS authentication.
ssl_context_factory (function): Factory function to create the SSL
context used to establish transport layer security. Defaults to
:func:`aioxmpp.security_layer.default_ssl_context`.
no_verify (:class:`bool`): *Disable* all certificate verification.
Usage is **strongly discouraged** outside controlled test
environments. See below for alternatives.
Raises:
RuntimeError: if `anonymous` is not :data:`False` and the version of
:mod:`aiosasl` does not support ANONYMOUS authentication.
Returns:
:class:`SecurityLayer`: object holding the entire security layer
configuration
`password_provider` must either be a coroutine function or a :class:`str`.
As a coroutine function, it is called during authentication with the JID we
are trying to authenticate against as the first, and the sequence number of
the authentication attempt as second argument. The sequence number starts
at 0. The coroutine is expected to return :data:`None` or a password. See
:class:`PasswordSASLProvider` for details. If `password_provider` is a
:class:`str`, a coroutine which returns the string on the first and
:data:`None` on subsequent attempts is created and used.
If `pin_store` is not :data:`None`, :class:`PinningPKIXCertificateVerifier`
is used instead of the default :class:`PKIXCertificateVerifier`. The
`pin_store` argument determines the pinned certificates: if it is a
dictionary, a :class:`AbstractPinStore` according to the :class:`PinType`
passed as `pin_type` argument is created and initialised with the data from
the dictionary using its :meth:`~AbstractPinStore.import_from_json` method.
Otherwise, `pin_store` must be a :class:`AbstractPinStore` instance which
is passed to the verifier.
`post_handshake_deferred_callback` is used only if `pin_store` is not
:data:`None`. It is passed to the equally-named argument of
:class:`PinningPKIXCertificateVerifier`, see the documentation there for
details on the semantics. If `post_handshake_deferred_callback` is
:data:`None` while `pin_store` is not, a coroutine which returns
:data:`False` is substituted.
`ssl_context_factory` can be a callable taking no arguments and returning
a :class:`OpenSSL.SSL.Context` object. If given, the factory will be used
to obtain an SSL context when the stream negotiates transport layer
security via TLS. By default,
:func:`aioxmpp.security_layer.default_ssl_context` is used, which should be
fine for most applications.
.. warning::
The :func:`~.default_ssl_context` implementation sets important
defaults. It is **strongly recommended** to use the context returned
by :func:`~.default_ssl_context` and modify it, instead of creating
a new context from scratch when implementing your own factory.
If `no_verify` is true, none of the above regarding certificate verifiers
matters. The internal null verifier is used, which **disables certificate
verification completely**.
.. warning::
Disabling certificate verification makes your application vulnerable to
trivial Man-in-the-Middle attacks. Do **not** use this outside
controlled test environments or when you know **exactly** what you’re
doing!
If you need to handle certificates which cannot be verified using the
public key infrastructure, consider making use of the `pin_store`
argument instead.
`anonymous` may be a string or :data:`False`. If it is not :data:`False`,
:class:`AnonymousSASLProvider` is used before password based authentication
is attempted. In addition, it is allowed to set `password_provider` to
:data:`None`. `anonymous` is the trace token to use, and SHOULD be the
empty string (as specified by :xep:`175`). This requires :mod:`aiosasl` 0.3
or newer.
.. note::
:data:`False` and ``""`` are treated differently for the `anonymous`
argument, despite both being false-y values!
.. note::
If `anonymous` is not :data:`False` and `password_provider` is not
:data:`None`, both authentication types are attempted. Anonymous
authentication is, in that case, preferred over password-based
authentication.
If you need to reverse the order, you have to construct your own
:class:`SecurityLayer` object.
.. warning::
Take the security and privacy considerations from :rfc:`4505` (which
specifies the ANONYMOUS SASL mechanism) and :xep:`175` (which discusses
the ANONYMOUS SASL mechanism in the XMPP context) into account before
using `anonymous`.
The versatility and simplicity of use of this function make (pun intended)
it the preferred way to construct :class:`SecurityLayer` instances.
.. versionadded:: 0.8
Support for SASL ANONYMOUS was added.
.. versionadded:: 0.11
Support for `ssl_context_factory`.
"""
if isinstance(password_provider, str):
static_password = password_provider
async def password_provider(jid, nattempt):
if nattempt == 0:
return static_password
return None
if pin_store is not None:
if post_handshake_deferred_failure is None:
async def post_handshake_deferred_failure(verifier):
return False
if not isinstance(pin_store, AbstractPinStore):
pin_data = pin_store
if pin_type == PinType.PUBLIC_KEY:
logger.debug("using PublicKeyPinStore")
pin_store = PublicKeyPinStore()
else:
logger.debug("using CertificatePinStore")
pin_store = CertificatePinStore()
pin_store.import_from_json(pin_data)
def certificate_verifier_factory():
return PinningPKIXCertificateVerifier(
pin_store.query,
post_handshake_deferred_failure,
)
elif no_verify:
certificate_verifier_factory = _NullVerifier
else:
certificate_verifier_factory = PKIXCertificateVerifier
sasl_providers = []
if anonymous is not False:
if AnonymousSASLProvider is None:
raise RuntimeError(
"aiosasl does not support ANONYMOUS, please upgrade"
)
sasl_providers.append(
AnonymousSASLProvider(anonymous)
)
if password_provider is not None:
sasl_providers.append(
PasswordSASLProvider(
password_provider,
),
)
return SecurityLayer(
ssl_context_factory,
certificate_verifier_factory,
True,
tuple(sasl_providers),
)
aioxmpp/service.py 0000664 0000000 0000000 00000140041 14160146213 0014543 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.service` --- Utilities for implementing :class:`~.Client` services
#################################################################################
Protocol extensions or in general support for parts of the XMPP protocol are
implemented using :class:`Service` classes, or rather, classes which use the
:class:`Meta` metaclass.
Both of these are provided in this module. To reduce the boilerplate required
to develop services, :ref:`decorators ` are
provided which can be used to easily register coroutines and functions as
stanza handlers, filters and others.
.. autoclass:: Service
.. _api-aioxmpp.service-decorators:
Decorators and Descriptors
==========================
These decorators provide special functionality when used on methods of
:class:`Service` subclasses.
.. note::
These decorators work only on methods declared on :class:`Service`
subclasses, as their functionality are implemented in cooperation with the
:class:`Meta` metaclass and :class:`Service` itself.
.. note::
These decorators and the descriptors (see below) are initialised in the
order in which they are declared at the class. In many cases, this does
not matter, but there are some corner cases.
For example: Suppose you have a class like this:
.. code-block:: python
class FooService(aioxmpp.service.Service):
feature = aioxmpp.disco.register_feature(
"some:namespace"
)
@aioxmpp.service.depsignal(aioxmpp.DiscoServer, "on_info_changed")
def handle_on_info_changed(self):
pass
In this case, the ``handle_on_info_changed`` method is not invoked during
startup of the ``FooService``. In this case however:
.. code-block:: python
class FooService(aioxmpp.service.Service):
@aioxmpp.service.depsignal(aioxmpp.DiscoServer, "on_info_changed")
def handle_on_info_changed(self):
pass
feature = aioxmpp.disco.register_feature(
"some:namespace"
)
The ``handle_on_info_changed`` *is* invoked during startup of the
``FooService`` because the ``some:namespace`` feature is registered
*after* the signal is connected.
.. versionchanged:: 0.9
This behaviour was introduced in version 0.9.
When using a descriptor and a :func:`depsignal`
connected to :meth:`.DiscoServer.on_info_changed`: if the
:class:`.disco.register_feature` is declared *before* the :func:`depsignal`,
the signal handler will not be invoked for that specific feature because
it is registered before the signal handler is connected).
.. autodecorator:: iq_handler
.. autodecorator:: message_handler
.. autodecorator:: presence_handler
.. autodecorator:: inbound_message_filter()
.. autodecorator:: inbound_presence_filter()
.. autodecorator:: outbound_message_filter()
.. autodecorator:: outbound_presence_filter()
.. autodecorator:: depsignal
.. autodecorator:: depfilter
.. autodecorator:: attrsignal
.. seealso::
:class:`~.disco.register_feature`
For a descriptor (see below) which allows to register a Service Discovery
feature when the service is instantiated.
:class:`~.disco.mount_as_node`
For a descriptor (see below) which allows to register a Service Discovery
node when the service is instantiated.
:class:`~.pep.register_pep_node`
For a descriptor (see below) which allows to register a PEP node
including notification features.
Test functions
--------------
.. autofunction:: is_iq_handler
.. autofunction:: is_message_handler
.. autofunction:: is_presence_handler
.. autofunction:: is_inbound_message_filter
.. autofunction:: is_inbound_presence_filter
.. autofunction:: is_outbound_message_filter
.. autofunction:: is_outbound_presence_filter
.. autofunction:: is_depsignal_handler
.. autofunction:: is_depfilter_handler
.. autofunction:: is_attrsignal_handler
Creating your own decorators
----------------------------
Sometimes, when you create your own service, it makes sense to create own
decorators which depending services can use to make easy use of some features
of your service.
.. note::
Remember that it isn’t necessary to create custom decorators to simply
connect a method to a signal exposed by another service. Users of that
service should be using :func:`depsignal` instead.
The key part is the :class:`HandlerSpec` object. It specifies the effect the
decorator has on initialisation and shutdown of the service. To add a
:class:`HandlerSpec` to a decorated method, use :func:`add_handler_spec` in the
implementation of your decorator.
.. autoclass:: HandlerSpec(key, is_unique=True, require_deps=[])
.. autofunction:: add_handler_spec
Creating your own descriptors
-----------------------------
Sometimes a decorator is not the right tool for the job, because with what you
attempt to achieve, there’s simply no relationship to a method.
In this case, subclassing :class:`Descriptor` is the way to go. It provides an
abstract base class implementing a :term:`descriptor`. Using a
:class:`Descriptor` subclass, you can create objects for each individual
service instance using the descriptor, including cleanup.
.. autoclass:: Descriptor
Metaclass
=========
.. autoclass:: Meta()
""" # NOQA: E501
import abc
import asyncio
import collections
import contextlib
import logging
import warnings
import weakref
import aioxmpp.callbacks
import aioxmpp.stream
def automake_magic_attr(obj):
obj._aioxmpp_service_handlers = getattr(
obj, "_aioxmpp_service_handlers", {}
)
return obj._aioxmpp_service_handlers
def get_magic_attr(obj):
return obj._aioxmpp_service_handlers
def has_magic_attr(obj):
return hasattr(
obj, "_aioxmpp_service_handlers"
)
class Descriptor(metaclass=abc.ABCMeta):
"""
Abstract base class for resource managing descriptors on :class:`Service`
classes.
While resources such as callback slots can easily be managed with
decorators (see above), because they are inherently related to the method
they use, others cannot. A :class:`Descriptor` provides a method to
initialise a context manager. The context manager is entered when the
service is initialised and left when the service is shut down, thus
providing a way for the :class:`Descriptor` to manage the resource
associated with it.
The result from entering the context manager is accessible by reading the
attribute the descriptor is bound to.
Subclasses must implement the following:
.. automethod:: init_cm
.. autoattribute:: value_type
Subclasses may override the following to modify the default behaviour:
.. autoattribute:: required_dependencies
.. automethod:: add_to_stack
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._data = weakref.WeakKeyDictionary()
@property
def required_dependencies(self):
"""
Iterable of services which must be declared as dependencies on a class
using this descriptor.
The default implementation returns an empty list.
"""
return []
@abc.abstractmethod
def init_cm(self, instance):
"""
Create and return a :term:`context manager`.
:param instance: The service instance for which the CM is used.
:return: A context manager managing the resource.
The context manager is responsible for acquiring, initialising,
destructing and releasing the resource managed by this descriptor.
The returned context manager is not stored anywhere in the descriptor,
it is the responsibility of the caller to register it appropriately.
"""
def add_to_stack(self, instance, stack):
"""
Get the context manager for the service `instance` and push it to the
context manager `stack`.
:param instance: The service to get the context manager for.
:type instance: :class:`Service`
:param stack: The context manager stack to push the CM onto.
:type stack: :class:`contextlib.ExitStack`
:return: The object returned by the context manager on enter.
If a context manager has already been created for `instance`, it is
re-used.
On subsequent calls to :meth:`__get__` for the given `instance`, the
return value of this method will be returned, that is, the value
obtained from entering the context.
"""
cm = self.init_cm(instance)
obj = stack.enter_context(cm)
self._data[instance] = cm, obj
return obj
def __get__(self, instance, owner):
if instance is None:
return self
try:
cm, obj = self._data[instance]
except KeyError:
raise AttributeError(
"resource manager descriptor has not been initialised"
)
return obj
@abc.abstractproperty
def value_type(self):
"""
The type of the value of the descriptor, once it is being accessed
as an object attribute.
.. versionadded:: 0.9
"""
class Meta(abc.ABCMeta):
"""
The metaclass for services. The :class:`Service` class uses it and in
general you should just inherit from :class:`Service` and define the
dependency attributes as needed.
Only use :class:`Meta` explicitly if you know what you are doing,
and you most likely do not. :class:`Meta` is internal API and may
change at any point.
Services have dependencies. A :class:`Meta` instance (i.e. a service class)
can declare dependencies using the following attributes.
.. attribute:: ORDER_BEFORE
An iterable of :class:`Service` classes before which the class which is
currently being declared needs to be instantiated.
Thus, any service which occurs in :attr:`ORDER_BEFORE` will be
instantiated *after* this class (if at all). Think of it as "*this*
class is ordered *before* the classes in this attribute".
.. versionadded:: 0.3
.. attribute:: SERVICE_BEFORE
Before 0.3, this was the name of the :attr:`ORDER_BEFORE` attribute. It
is still supported, but use emits a :data:`DeprecationWarning`. It must
not be mixed with :attr:`ORDER_BEFORE` or :attr:`ORDER_AFTER` on a class
declaration, or the declaration will raise :class:`ValueError`.
.. deprecated:: 0.3
Support for this attribute will be removed in 1.0; starting with 1.0,
using this attribute will raise a :class:`TypeError` on class
declaration and a :class:`AttributeError` when accessing it on a
class or instance.
.. attribute:: ORDER_AFTER
An iterable of :class:`Service` classes which will be instantiated
*before* the class which is being declraed.
Classes which are declared in this attribute are always instantiated
before this class is instantiated. Think of it as "*this* class is
ordered *after* the classes in this attribute".
.. versionadded:: 0.3
.. attribute:: SERVICE_AFTER
Before 0.3, this was the name of the :attr:`ORDER_AFTER` attribute. It
is still supported, but use emits a :data:`DeprecationWarning`. It must
not be mixed with :attr:`ORDER_BEFORE` or :attr:`ORDER_AFTER` on a class
declaration, or the declaration will raise :class:`ValueError`.
.. deprecated:: 0.3
See :attr:`SERVICE_BEFORE` for details on the deprecation cycle.
Further, the following attributes are generated:
.. attribute:: PATCHED_ORDER_AFTER
An iterable of :class:`Service` classes. This includes all
classes in :attr:`ORDER_AFTER` and all classes which specify the class
in :attr:`ORDER_BEFORE`.
This is primarily used internally to handle :attr:`ORDER_BEFORE` when
summoning services.
It is an error to manually define :attr:`PATCHED_ORDER_AFTER` in a class
definition, doing so will raise a :class:`TypeError`.
.. versionadded:: 0.9
.. versionchanged:: 0.9
The :attr:`ORDER_AFTER` and :attr:`ORDER_BEFORE` attribute do not
change after class creation. In earlier versions they contained
the transitive completion of the dependency relation.
The following attribute was generated in earlier version of
aioxmpp:
.. attribute:: _DEPGRAPH_NODE
For compatibility with earlier versions, a warning is issued
when :attr:`_DEPGRAPH_NODE` is defined in a service class
definition.
This behaviour will be removed in aioxmpp 1.0.
.. deprecated:: 0.11
Dependency relationships must not have cycles; a cycle results in a
:class:`ValueError` when the class causing the cycle is declared.
.. note::
Subclassing instances of :class:`Meta` is forbidden. Trying to do so
will raise a :class:`TypeError`
.. versionchanged:: 0.9
Example::
class Foo(metaclass=service.Meta):
pass
class Bar(metaclass=service.Meta):
ORDER_BEFORE = [Foo]
class Baz(metaclass=service.Meta):
ORDER_BEFORE = [Bar]
class Fourth(metaclass=service.Meta):
ORDER_BEFORE = [Bar]
``Baz`` and ``Fourth`` will be instantiated before ``Bar`` and ``Bar`` will
be instantiated before ``Foo``. There is no dependency relationship between
``Baz`` and ``Fourth``.
"""
def __new__(mcls, name, bases, namespace, inherit_dependencies=True):
if "SERVICE_BEFORE" in namespace or "SERVICE_AFTER" in namespace:
if "ORDER_BEFORE" in namespace or "ORDER_AFTER" in namespace:
raise ValueError("declaration mixes old and new ordering "
"attribute names (SERVICE_* vs. ORDER_*)")
warnings.warn(
"SERVICE_BEFORE/AFTER used on class; use ORDER_BEFORE/AFTER",
DeprecationWarning)
try:
namespace["ORDER_BEFORE"] = namespace.pop("SERVICE_BEFORE")
except KeyError:
pass
try:
namespace["ORDER_AFTER"] = namespace.pop("SERVICE_AFTER")
except KeyError:
pass
if "PATCHED_ORDER_AFTER" in namespace:
raise TypeError(
"PATCHED_ORDER_AFTER must not be defined manually. "
"it is supplied automatically by the metaclass."
)
if "_DEPGRAPH_NODE" in namespace:
warnings.warn(
"_DEPGRAPH_NODE should not be defined manually. "
"In version before 0.11 it was supplied automatically by "
"the metaclass and defining it raised TypeError."
)
if any(isinstance(mcls, base)
for base in bases) and "service_order_index" in namespace:
raise TypeError(
"service_order_index must not be defined manually. "
"It is supplied automatically by the metaclass."
)
for base in bases:
if isinstance(base, Meta) and base is not Service:
raise TypeError(
"subclassing services is prohibited."
)
for base in bases:
if hasattr(base, "SERVICE_HANDLERS") and base.SERVICE_HANDLERS:
raise TypeError(
"inheritance from service class with handlers is forbidden"
)
namespace["ORDER_BEFORE"] = frozenset(
namespace.get("ORDER_BEFORE", ()))
namespace["ORDER_AFTER"] = frozenset(
namespace.get("ORDER_AFTER", ()))
namespace["PATCHED_ORDER_AFTER"] = namespace["ORDER_AFTER"]
if namespace["ORDER_BEFORE"] and namespace["ORDER_AFTER"]:
visited = set()
for item in namespace["PATCHED_ORDER_AFTER"]:
if item.orders_after_any(namespace["ORDER_BEFORE"],
visited=visited):
raise ValueError("dependency loop in service definitions")
SERVICE_HANDLERS = []
existing_handlers = set()
for attr_name, attr_value in namespace.items():
if has_magic_attr(attr_value):
new_handlers = get_magic_attr(attr_value)
unique_handlers = {
spec.key
for spec in new_handlers
if spec.is_unique
}
conflicting = unique_handlers & existing_handlers
if conflicting:
key = next(iter(conflicting))
obj = next(iter(
obj
for obj_key, obj, _ in SERVICE_HANDLERS
if obj_key == key
))
raise TypeError(
"handler conflict between {!r} and {!r}: "
"both want to use {!r}".format(
obj,
attr_value,
key,
)
)
existing_handlers |= unique_handlers
for spec, kwargs in new_handlers.items():
missing = spec.require_deps - namespace["ORDER_AFTER"]
if missing:
raise TypeError(
"decorator requires dependency {!r} "
"but it is not declared".format(
next(iter(missing))
)
)
SERVICE_HANDLERS.append(
(spec.key, attr_value, kwargs)
)
elif isinstance(attr_value, Descriptor):
missing = set(attr_value.required_dependencies) - \
namespace["ORDER_AFTER"]
if missing:
raise TypeError(
"descriptor requires dependency {!r} "
"but it is not declared".format(
next(iter(missing)),
)
)
SERVICE_HANDLERS.append(attr_value)
namespace["SERVICE_HANDLERS"] = tuple(SERVICE_HANDLERS)
return super().__new__(mcls, name, bases, namespace)
def __init__(self, name, bases, namespace, inherit_dependencies=True):
super().__init__(name, bases, namespace)
for cls in self.ORDER_BEFORE:
cls.PATCHED_ORDER_AFTER |= frozenset([self])
def __prepare__(*args, **kwargs):
return collections.OrderedDict()
@property
def SERVICE_BEFORE(self):
return self.ORDER_BEFORE
@property
def SERVICE_AFTER(self):
return self.ORDER_AFTER
def orders_after(self, other, *, visited=None):
"""
Return whether `self` depends on `other` and will be instantiated
later.
:param other: Another service.
:type other: :class:`aioxmpp.service.Service`
.. versionadded:: 0.11
"""
return self.orders_after_any(frozenset([other]), visited=visited)
def orders_after_any(self, other, *, visited=None):
"""
Return whether `self` orders after any of the services in the set
`other`.
:param other: Another service.
:type other: A :class:`set` of
:class:`aioxmpp.service.Service` instances
.. versionadded:: 0.11
"""
if not other:
return False
if visited is None:
visited = set()
elif self in visited:
return False
visited.add(self)
for item in self.PATCHED_ORDER_AFTER:
if item in visited:
continue
if item in other:
return True
if item.orders_after_any(other, visited=visited):
return True
return False
def independent_from(self, other):
"""
Return whether the services are independent (neither depends on
the other).
:param other: Another service.
:type other: :class:`aioxmpp.service.Service`
.. versionadded:: 0.11
"""
if self is other:
return False
return not self.orders_after(other) and not other.orders_after(self)
class Service(metaclass=Meta):
"""
A :class:`Service` is used to implement XMPP or XEP protocol parts, on top
of the more or less fixed stanza handling implemented in
:mod:`aioxmpp.node` and :mod:`aioxmpp.stream`.
:class:`Service` is a base class which can be used by extension developers
to implement support for custom or standardized protocol extensions. Some
of the features for which :mod:`aioxmpp` has support are also implemented
using :class:`Service` subclasses.
`client` must be a :class:`~.Client` to which the service will be attached.
The `client` cannot be changed later, for the sake of simplicity.
`logger_base` may be a :class:`logging.Logger` instance or :data:`None`. If
it is :data:`None`, a logger is automatically created, by taking the fully
qualified name of the :class:`Service` subclass which is being
instantiated. Otherwise, the logger is passed to :meth:`derive_logger` and
the result is used as value for the :attr:`logger` attribute.
To implement your own service, derive from :class:`Service`. If your
service depends on other services (such as :mod:`aioxmpp.pubsub` or
:mod:`aioxmpp.disco`), these dependencies *must* be declared as documented
in the service meta class :class:`Meta`.
To stay forward compatible, accept arbitrary keyword arguments and pass
them down to :class:`Service`. As it is not possible to directly pass
arguments to :class:`Service`\\ s on construction (due to the way
:meth:`aioxmpp.Client.summon` works), there is no need for you
to introduce custom arguments, and thus there should be no conflicts.
.. note::
Inheritance from classes which subclass :class:`Service` is forbidden.
.. versionchanged:: 0.9
.. autoattribute:: client
.. autoattribute:: dependencies
.. autoattribute:: service_order_index
.. automethod:: derive_logger
.. automethod:: shutdown
"""
def __init__(self, client, *, logger_base=None, dependencies={},
service_order_index=0):
if logger_base is None:
self.logger = logging.getLogger(".".join([
type(self).__module__, type(self).__qualname__
]))
else:
self.logger = self.derive_logger(logger_base)
super().__init__()
self.__context = contextlib.ExitStack()
self.__client = client
self.__dependencies = dependencies
self.__service_order_index = service_order_index
for item in self.SERVICE_HANDLERS:
if isinstance(item, Descriptor):
item.add_to_stack(self, self.__context)
else:
(handler_cm, additional_args), obj, kwargs = item
self.__context.enter_context(
handler_cm(
self,
self.__client.stream,
obj.__get__(self, type(self)),
*additional_args,
**kwargs
)
)
@property
def service_order_index(self):
"""
Return the index of this service in the toposort of summoned
services. This is primarily used to order filter chain
registrations consistently with the dependency relationship of
the services.
.. versionadded:: 0.11
"""
return self.__service_order_index
def derive_logger(self, logger):
"""
Return a child of `logger` specific for this instance. This is called
after :attr:`client` has been set, from the constructor.
The child name is calculated by the default implementation in a way
specific for aioxmpp services; it is not meant to be used by
non-:mod:`aioxmpp` classes; do not rely on the way how the child name
is calculated.
"""
parts = type(self).__module__.split(".")[1:]
if parts[-1] == "service" and len(parts) > 1:
del parts[-1]
return logger.getChild(".".join(
parts+[type(self).__qualname__]
))
@property
def client(self):
"""
The client to which the :class:`Service` is bound. This attribute is
read-only.
If the service has been shut down using :meth:`shutdown`, this reads as
:data:`None`.
"""
return self.__client
@property
def dependencies(self):
"""
When the service is instantiated through
:meth:`~.Client.summon`, this attribute holds a mapping which maps the
service classes contained in the :attr:`~.Meta.ORDER_AFTER` attribute
to the respective instances related to the :attr:`client`.
This is the preferred way to obtain dependencies specified via
:attr:`~.Meta.ORDER_AFTER`.
"""
return self.__dependencies
async def _shutdown(self):
"""
Actual implementation of the shut down process.
This *must* be called using super from inheriting classes after their
own shutdown procedure. Inheriting classes *must* override this method
instead of :meth:`shutdown`.
"""
async def shutdown(self):
"""
Close the service and wait for it to completely shut down.
Some services which are still running may depend on this service. In
that case, the service may refuse to shut down instead of shutting
down, by raising a :class:`RuntimeError` exception.
.. note::
Developers creating subclasses of :class:`Service` to implement
services should not override this method. Instead, they should
override the :meth:`_shutdown` method.
"""
await self._shutdown()
self.__context.close()
self.__client = None
class HandlerSpec(collections.namedtuple(
"HandlerSpec",
[
"is_unique",
"key",
"require_deps",
])):
"""
Specification of the effects of the decorator at initialisation and shutdown
time.
:param key: Context manager and arguments pair.
:type key: pair
:param is_unique: Whether multiple identical `key` values are allowed on a
single class.
:type is_unique: :class:`bool`
:param require_deps: Dependent services which are required for the
decorator to work.
:type require_deps: iterable of :class:`Service` classes
During initialisation of the :class:`Service` which has a method using a
given handler spec, the first part of the `key` pair is called with the
service instance as first, the client :class:`StanzaStream` as second and
the bound method as third argument. The second part of the `key` is
unpacked as additional positional arguments.
The result of the call must be a context manager, which is immediately
entered. On shutdown, the context manager is exited.
An example use would be the following handler spec::
HandlerSpec(
(func, (IQType.GET, some_payload_class)),
is_unique=True,
)
where ``func`` is a context manager which takes a service instance, a
stanza stream, a bound method as well as an IQ type and a payload class. On
enter, the context manager would register the method it received as third
argument on the stanza stream (second argument) as handler for the given IQ
type and payload class (fourth and fifth arguments).
If `is_unique` is true and several methods have :class:`HandlerSpec`
objects with the same `key`, :class:`TypeError` is raised at class
definition time.
If at class definition time any of the dependent classes in `require_deps`
are not declared using the order attributes (see :class:`Meta`), a
:class:`TypeError` is raised.
There is a property to extract the function directly:
.. autoattribute:: func
"""
def __new__(cls, key, is_unique=True, require_deps=()):
return super().__new__(cls, is_unique, key, frozenset(require_deps))
@property
def func(self):
"""
The factory of the context manager for this handler.
.. versionadded:: 0.11
"""
return self.key[0]
def add_handler_spec(f, handler_spec, *, kwargs=None):
"""
Attach a handler specification (see :class:`HandlerSpec`) to a function.
:param f: Function to attach the handler specification to.
:param handler_spec: Handler specification to attach to the function.
:type handler_spec: :class:`HandlerSpec`
:param kwargs: additional keyword arguments passed to the function
carried in the handler spec.
:type kwargs: :class:`dict`
:raises ValueError: if the handler was registered with
different `kwargs` before
This uses a private attribute, whose exact name is an implementation
detail. The `handler_spec` is stored in a :class:`dict` bound to the
attribute.
.. versionadded:: 0.11
The `kwargs` argument. If two handlers with the same spec, but
different arguments are registered for one function, an error
will be raised. So you should always include all possible
arguments, this is the responsibility of the calling decorator.
"""
handler_dict = automake_magic_attr(f)
if kwargs is None:
kwargs = {}
if kwargs != handler_dict.setdefault(handler_spec, kwargs):
raise ValueError(
"The additional keyword arguments to the handler are incompatible")
def _apply_iq_handler(instance, stream, func, type_, payload_cls, *,
with_send_reply=False):
return aioxmpp.stream.iq_handler(stream, type_, payload_cls, func,
with_send_reply=with_send_reply)
def _apply_presence_handler(instance, stream, func, type_, from_):
return aioxmpp.stream.presence_handler(stream, type_, from_, func)
def _apply_inbound_message_filter(instance, stream, func):
return aioxmpp.stream.stanza_filter(
stream.service_inbound_message_filter,
func,
instance.service_order_index,
)
def _apply_inbound_presence_filter(instance, stream, func):
return aioxmpp.stream.stanza_filter(
stream.service_inbound_presence_filter,
func,
instance.service_order_index,
)
def _apply_outbound_message_filter(instance, stream, func):
return aioxmpp.stream.stanza_filter(
stream.service_outbound_message_filter,
func,
instance.service_order_index,
)
def _apply_outbound_presence_filter(instance, stream, func):
return aioxmpp.stream.stanza_filter(
stream.service_outbound_presence_filter,
func,
instance.service_order_index,
)
def _apply_connect_depsignal(instance, stream, func, dependency, signal_name,
mode):
if dependency is aioxmpp.stream.StanzaStream:
dependency = instance.client.stream
elif dependency is aioxmpp.node.Client:
dependency = instance.client
else:
dependency = instance.dependencies[dependency]
signal = getattr(dependency, signal_name)
if mode is None:
return signal.context_connect(func)
else:
try:
mode_func, args = mode
except TypeError:
pass
else:
mode = mode_func(*args)
return signal.context_connect(func, mode)
def _apply_connect_depfilter(instance, stream, func, dependency, filter_name):
if dependency is aioxmpp.stream.StanzaStream:
dependency = instance.client.stream
else:
dependency = instance.dependencies[dependency]
filter_ = getattr(dependency, filter_name)
return filter_.context_register(func, instance.service_order_index)
def _apply_connect_attrsignal(instance, stream, func, descriptor, signal_name,
mode):
obj = descriptor.__get__(instance, type(instance))
signal = getattr(obj, signal_name)
if mode is None:
return signal.context_connect(func)
else:
try:
mode_func, args = mode
except TypeError:
pass
else:
mode = mode_func(*args)
return signal.context_connect(func, mode)
def iq_handler(type_, payload_cls, *, with_send_reply=False):
"""
Register the decorated function or coroutine function as IQ request
handler.
:param type_: IQ type to listen for
:type type_: :class:`~.IQType`
:param payload_cls: Payload XSO class to listen for
:type payload_cls: :class:`~.XSO` subclass
:param with_send_reply: Whether to pass a function to send a reply
to the decorated callable as second argument.
:type with_send_reply: :class:`bool`
:raises ValueError: if `payload_cls` is not a registered IQ payload
If the decorated function is not a coroutine function, it must return an
awaitable instead.
.. seealso::
:meth:`~.StanzaStream.register_iq_request_handler` for more
details on the `type_`, `payload_cls` and
`with_send_reply` arguments, as well as behaviour expected
from the decorated function.
:meth:`aioxmpp.IQ.as_payload_class`
for a way to register a XSO as IQ payload
.. versionadded:: 0.11
The `with_send_reply` argument.
.. versionchanged:: 0.10
The decorator now checks if `payload_cls` is a valid, registered IQ
payload and raises :class:`ValueError` if not.
"""
if (not hasattr(payload_cls, "TAG") or
(aioxmpp.IQ.CHILD_MAP.get(payload_cls.TAG) is not
aioxmpp.IQ.payload.xq_descriptor) or
payload_cls not in aioxmpp.IQ.payload._classes):
raise ValueError(
"{!r} is not a valid IQ payload "
"(use IQ.as_payload_class decorator)".format(
payload_cls,
)
)
def decorator(f):
add_handler_spec(
f,
HandlerSpec(
(_apply_iq_handler, (type_, payload_cls)),
require_deps=(),
),
kwargs=dict(with_send_reply=with_send_reply),
)
return f
return decorator
def message_handler(type_, from_):
"""
Deprecated alias of :func:`.dispatcher.message_handler`.
.. deprecated:: 0.9
"""
import aioxmpp.dispatcher
return aioxmpp.dispatcher.message_handler(type_, from_)
def presence_handler(type_, from_):
"""
Deprecated alias of :func:`.dispatcher.presence_handler`.
.. deprecated:: 0.9
"""
import aioxmpp.dispatcher
return aioxmpp.dispatcher.presence_handler(type_, from_)
def inbound_message_filter(f):
"""
Register the decorated function as a service-level inbound message filter.
:raise TypeError: if the decorated object is a coroutine function
.. seealso::
:class:`StanzaStream`
for important remarks regarding the use of stanza filters.
"""
if asyncio.iscoroutinefunction(f):
raise TypeError(
"inbound_message_filter must not be a coroutine function"
)
add_handler_spec(
f,
HandlerSpec(
(_apply_inbound_message_filter, ())
),
)
return f
def inbound_presence_filter(f):
"""
Register the decorated function as a service-level inbound presence filter.
:raise TypeError: if the decorated object is a coroutine function
.. seealso::
:class:`StanzaStream`
for important remarks regarding the use of stanza filters.
"""
if asyncio.iscoroutinefunction(f):
raise TypeError(
"inbound_presence_filter must not be a coroutine function"
)
add_handler_spec(
f,
HandlerSpec(
(_apply_inbound_presence_filter, ())
),
)
return f
def outbound_message_filter(f):
"""
Register the decorated function as a service-level outbound message filter.
:raise TypeError: if the decorated object is a coroutine function
.. seealso::
:class:`StanzaStream`
for important remarks regarding the use of stanza filters.
"""
if asyncio.iscoroutinefunction(f):
raise TypeError(
"outbound_message_filter must not be a coroutine function"
)
add_handler_spec(
f,
HandlerSpec(
(_apply_outbound_message_filter, ())
),
)
return f
def outbound_presence_filter(f):
"""
Register the decorated function as a service-level outbound presence
filter.
:raise TypeError: if the decorated object is a coroutine function
.. seealso::
:class:`StanzaStream`
for important remarks regarding the use of stanza filters.
"""
if asyncio.iscoroutinefunction(f):
raise TypeError(
"outbound_presence_filter must not be a coroutine function"
)
add_handler_spec(
f,
HandlerSpec(
(_apply_outbound_presence_filter, ())
),
)
return f
def _signal_connect_mode(signal, f, defer):
if isinstance(signal, aioxmpp.callbacks.SyncSignal):
if not asyncio.iscoroutinefunction(f):
raise TypeError(
"a coroutine function is required for this signal"
)
if defer:
raise ValueError(
"cannot use defer with this signal"
)
mode = None
else:
if asyncio.iscoroutinefunction(f):
if defer:
mode = aioxmpp.callbacks.AdHocSignal.SPAWN_WITH_LOOP, (None,)
else:
raise TypeError(
"cannot use coroutine function with this signal"
" without defer"
)
elif defer:
mode = aioxmpp.callbacks.AdHocSignal.ASYNC_WITH_LOOP, (None,)
else:
mode = aioxmpp.callbacks.AdHocSignal.STRONG
return mode
def _depsignal_spec(class_, signal_name, f, defer):
signal = getattr(class_, signal_name)
mode = _signal_connect_mode(signal, f, defer)
if (class_ is not aioxmpp.stream.StanzaStream and
class_ is not aioxmpp.node.Client):
deps = (class_,)
else:
deps = ()
return HandlerSpec(
(
_apply_connect_depsignal,
(
class_,
signal_name,
mode,
)
),
require_deps=deps,
)
def depsignal(class_, signal_name, *, defer=False):
"""
Connect the decorated method or coroutine method to the addressed signal on
a class on which the service depends.
:param class_: A service class which is listed in the
:attr:`~.Meta.ORDER_AFTER` relationship.
:type class_: :class:`Service` class or one of the special cases below
:param signal_name: Attribute name of the signal to connect to
:type signal_name: :class:`str`
:param defer: Flag indicating whether deferred execution of the decorated
method is desired; see below for details.
:type defer: :class:`bool`
The signal is discovered by accessing the attribute with the name
`signal_name` on the given `class_`. In addition, the following arguments
are supported for `class_`:
1. :class:`aioxmpp.stream.StanzaStream`: the corresponding signal of the
stream of the client running the service is used.
2. :class:`aioxmpp.Client`: the corresponding signal of the client running
the service is used.
If the signal is a :class:`.callbacks.Signal` and `defer` is false, the
decorated object is connected using the default
:attr:`~.callbacks.AdHocSignal.STRONG` mode.
If the signal is a :class:`.callbacks.Signal` and `defer` is true and the
decorated object is a coroutine function, the
:attr:`~.callbacks.AdHocSignal.SPAWN_WITH_LOOP` mode with the default
asyncio event loop is used. If the decorated object is not a coroutine
function, :attr:`~.callbacks.AdHocSignal.ASYNC_WITH_LOOP` is used instead.
If the signal is a :class:`.callbacks.SyncSignal`, `defer` must be false
and the decorated object must be a coroutine function.
.. versionchanged:: 0.9
Support for :class:`aioxmpp.stream.StanzaStream` and
:class:`aioxmpp.Client` as `class_` argument was added.
"""
def decorator(f):
add_handler_spec(
f,
_depsignal_spec(class_, signal_name, f, defer)
)
return f
return decorator
def _attrsignal_spec(descriptor, signal_name, f, defer):
signal = getattr(descriptor.value_type, signal_name)
mode = _signal_connect_mode(signal, f, defer)
return HandlerSpec(
(
_apply_connect_attrsignal,
(
descriptor,
signal_name,
mode
)
),
is_unique=True,
require_deps=(),
)
def attrsignal(descriptor, signal_name, *, defer=False):
"""
Connect the decorated method or coroutine method to the addressed signal on
a descriptor.
:param descriptor: The descriptor to connect to.
:type descriptor: :class:`Descriptor` subclass.
:param signal_name: Attribute name of the signal to connect to
:type signal_name: :class:`str`
:param defer: Flag indicating whether deferred execution of the decorated
method is desired; see below for details.
:type defer: :class:`bool`
The signal is discovered by accessing the attribute with the name
`signal_name` on the :attr:`~Descriptor.value_type` of the `descriptor`.
During instantiation of the service, the value of the descriptor is used
to obtain the signal and then the decorated method is connected to the
signal.
If the signal is a :class:`.callbacks.Signal` and `defer` is false, the
decorated object is connected using the default
:attr:`~.callbacks.AdHocSignal.STRONG` mode.
If the signal is a :class:`.callbacks.Signal` and `defer` is true and the
decorated object is a coroutine function, the
:attr:`~.callbacks.AdHocSignal.SPAWN_WITH_LOOP` mode with the default
asyncio event loop is used. If the decorated object is not a coroutine
function, :attr:`~.callbacks.AdHocSignal.ASYNC_WITH_LOOP` is used instead.
If the signal is a :class:`.callbacks.SyncSignal`, `defer` must be false
and the decorated object must be a coroutine function.
.. versionadded:: 0.9
"""
def decorator(f):
add_handler_spec(
f,
_attrsignal_spec(descriptor, signal_name, f, defer)
)
return f
return decorator
def _depfilter_spec(class_, filter_name):
require_deps = ()
if class_ is not aioxmpp.stream.StanzaStream:
require_deps = (class_,)
return HandlerSpec(
(
_apply_connect_depfilter,
(
class_,
filter_name,
)
),
is_unique=True,
require_deps=require_deps,
)
def depfilter(class_, filter_name):
"""
Register the decorated method at the addressed :class:`~.callbacks.Filter`
on a class on which the service depends.
:param class_: A service class which is listed in the
:attr:`~.Meta.ORDER_AFTER` relationship.
:type class_: :class:`Service` class or
:class:`aioxmpp.stream.StanzaStream`
:param filter_name: Attribute name of the filter to register at
:type filter_name: :class:`str`
The filter at which the decorated method is registered is discovered by
accessing the attribute with the name `filter_name` on the instance of the
dependent class `class_`. If `class_` is
:class:`aioxmpp.stream.StanzaStream`, the filter is searched for on the
stream (and no dependendency needs to be declared).
.. versionadded:: 0.9
"""
spec = _depfilter_spec(class_, filter_name)
def decorator(f):
add_handler_spec(
f,
spec,
)
return f
return decorator
def is_iq_handler(type_, payload_cls, coro, *, with_send_reply=False):
"""
Return true if `coro` has been decorated with :func:`iq_handler` for the
given `type_` and `payload_cls` and the specified keyword arguments.
"""
try:
handlers = get_magic_attr(coro)
except AttributeError:
return False
hs = HandlerSpec(
(_apply_iq_handler, (type_, payload_cls)),
)
try:
return handlers[hs] == dict(with_send_reply=with_send_reply)
except KeyError:
return False
def is_message_handler(type_, from_, cb):
"""
Deprecated alias of :func:`.dispatcher.is_message_handler`.
.. deprecated:: 0.9
"""
import aioxmpp.dispatcher
return aioxmpp.dispatcher.is_message_handler(type_, from_, cb)
def is_presence_handler(type_, from_, cb):
"""
Deprecated alias of :func:`.dispatcher.is_presence_handler`.
.. deprecated:: 0.9
"""
import aioxmpp.dispatcher
return aioxmpp.dispatcher.is_presence_handler(type_, from_, cb)
def is_inbound_message_filter(cb):
"""
Return true if `cb` has been decorated with :func:`inbound_message_filter`.
"""
try:
handlers = get_magic_attr(cb)
except AttributeError:
return False
hs = HandlerSpec(
(_apply_inbound_message_filter, ())
)
return hs in handlers
def is_inbound_presence_filter(cb):
"""
Return true if `cb` has been decorated with
:func:`inbound_presence_filter`.
"""
try:
handlers = get_magic_attr(cb)
except AttributeError:
return False
hs = HandlerSpec(
(_apply_inbound_presence_filter, ())
)
return hs in handlers
def is_outbound_message_filter(cb):
"""
Return true if `cb` has been decorated with
:func:`outbound_message_filter`.
"""
try:
handlers = get_magic_attr(cb)
except AttributeError:
return False
hs = HandlerSpec(
(_apply_outbound_message_filter, ())
)
return hs in handlers
def is_outbound_presence_filter(cb):
"""
Return true if `cb` has been decorated with
:func:`outbound_presence_filter`.
"""
try:
handlers = get_magic_attr(cb)
except AttributeError:
return False
hs = HandlerSpec(
(_apply_outbound_presence_filter, ())
)
return hs in handlers
def is_depsignal_handler(class_, signal_name, cb, *, defer=False):
"""
Return true if `cb` has been decorated with :func:`depsignal` for the given
signal, class and connection mode.
"""
try:
handlers = get_magic_attr(cb)
except AttributeError:
return False
return _depsignal_spec(class_, signal_name, cb, defer) in handlers
def is_depfilter_handler(class_, filter_name, filter_):
"""
Return true if `filter_` has been decorated with :func:`depfilter` for the
given filter and class.
"""
try:
handlers = get_magic_attr(filter_)
except AttributeError:
return False
return _depfilter_spec(class_, filter_name) in handlers
def is_attrsignal_handler(descriptor, signal_name, cb, *, defer=False):
"""
Return true if `cb` has been decorated with :func:`attrsignal` for the
given signal, descriptor and connection mode.
"""
try:
handlers = get_magic_attr(cb)
except AttributeError:
return False
return _attrsignal_spec(descriptor, signal_name, cb, defer) in handlers
aioxmpp/shim/ 0000775 0000000 0000000 00000000000 14160146213 0013471 5 ustar 00root root 0000000 0000000 aioxmpp/shim/__init__.py 0000664 0000000 0000000 00000003563 14160146213 0015611 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.shim` --- Stanza Headers and Internet Metadata (:xep:`0131`)
###########################################################################
This module provides support for :xep:`131` stanza headers. The following
attributes are added by this module to the existing stanza classes:
.. attribute:: aioxmpp.Message.xep0131_headers
A :class:`xso.Headers` instance or :data:`None`. Represents the SHIM
headers of the stanza.
.. attribute:: aioxmpp.Presence.xep0131_headers
A :class:`xso.Headers` instance or :data:`None`. Represents the SHIM
headers of the stanza.
The attributes are available as soon as :mod:`aioxmpp.shim` is loaded.
.. currentmodule:: aioxmpp
.. autoclass:: SHIMService
.. currentmodule:: aioxmpp.shim
.. class:: Service
Alias of :class:`.SHIMService`.
.. deprecated:: 0.8
The alias will be removed in 1.0.
.. currentmodule:: aioxmpp.shim.xso
.. autoclass:: Headers
"""
from . import xso # NOQA: F401
from .service import ( # NOQA: F401
SHIMService,
)
aioxmpp/shim/service.py 0000664 0000000 0000000 00000005521 14160146213 0015506 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import aioxmpp.service
from aioxmpp.utils import namespaces
class SHIMService(aioxmpp.service.Service):
"""
This service implements :xep:`131` feature advertisement.
It registers the ``http://jabber.org/protocol/shim`` node with the
:class:`.DiscoServer`. It publishes the supported headers on that node as
specified in the XEP.
To announce supported headers, use the :meth:`register_header` and
:meth:`unregister_header` methods.
.. automethod:: register_header
.. automethod:: unregister_header
.. versionchanged:: 0.8
This class was formerly known as :class:`aioxmpp.shim.Service`. It
is still available under that name, but the alias will be removed in
1.0.
"""
ORDER_AFTER = [aioxmpp.DiscoServer]
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._disco = self.dependencies[aioxmpp.DiscoServer]
self._disco.register_feature(namespaces.xep0131_shim)
self._node = aioxmpp.disco.StaticNode()
self._disco.mount_node(
namespaces.xep0131_shim,
self._node
)
async def _shutdown(self):
self._disco.unregister_feature(namespaces.xep0131_shim)
self._disco.unmount_node(namespaces.xep0131_shim)
await super()._shutdown()
def register_header(self, name):
"""
Register support for the SHIM header with the given `name`.
If the header has already been registered as supported,
:class:`ValueError` is raised.
"""
self._node.register_feature(
"#".join([namespaces.xep0131_shim, name])
)
def unregister_header(self, name):
"""
Unregister support for the SHIM header with the given `name`.
If the header is currently not registered as supported,
:class:`KeyError` is raised.
"""
self._node.unregister_feature(
"#".join([namespaces.xep0131_shim, name])
)
aioxmpp/shim/xso.py 0000664 0000000 0000000 00000004576 14160146213 0014670 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import multidict
import aioxmpp.stanza
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0131_shim = "http://jabber.org/protocol/shim"
class Header(xso.XSO):
TAG = (namespaces.xep0131_shim, "header")
name = xso.Attr(
"name",
)
value = xso.Text()
def __init__(self, name, value):
super().__init__()
self.name = name
self.value = value
class HeaderType(xso.AbstractElementType):
def get_xso_types(self):
return [Header]
def unpack(self, v):
return v.name, v.value
def pack(self, v):
name, value = v
return Header(
name,
value,
)
class Headers(xso.XSO):
"""
Represent stanza headers. The headers are accessible at the :attr:`headers`
attribute.
.. attribute:: headers
A :class:`multidict.CIMultiDict` which provides access to the headers.
The keys are the header names and the values are the values of the
header. Both must be strings.
.. seealso::
:attr:`.Message.xep0131_headers`
SHIM headers for :class:`~.Message` stanzas
:attr:`.Presence.xep0131_headers`
SHIM headers for :class:`~.Presence` stanzas
"""
TAG = (namespaces.xep0131_shim, "header")
headers = xso.ChildValueMultiMap(
HeaderType(),
mapping_type=multidict.CIMultiDict,
)
aioxmpp.stanza.Message.xep0131_headers = xso.Child([
Headers,
])
aioxmpp.stanza.Presence.xep0131_headers = xso.Child([
Headers,
])
aioxmpp/ssl_transport.py 0000664 0000000 0000000 00000001635 14160146213 0016025 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: ssl_transport.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
# .
#
########################################################################
from aioopenssl import * # NOQA: F403,F401
aioxmpp/stanza.py 0000664 0000000 0000000 00000077132 14160146213 0014415 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: stanza.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.stanza` --- XSOs for dealing with stanzas
########################################################
This module provides :class:`~.xso.XSO` subclasses which provide access to
stanzas and their RFC6120-defined child elements.
Much of what you’ll read here makes much more sense if you have read
`RFC 6120 `_.
Top-level classes
=================
.. autoclass:: StanzaBase(*[, from_][, to][, id_])
.. currentmodule:: aioxmpp
.. autoclass:: Message(*[, from_][, to][, id_][, type_])
.. autoclass:: IQ(*[, from_][, to][, id_][, type_])
.. autoclass:: Presence(*[, from_][, to][, id_][, type_])
.. currentmodule:: aioxmpp.stanza
Payload classes
===============
For :class:`Presence` and :class:`Message` as well as :class:`IQ` errors, the
standardized payloads also have classes which are used as values for the
attributes:
.. autoclass:: Error(*[, condition][, type_][, text])
.. autofunction:: make_application_error
For messages
------------
.. autoclass:: Thread()
.. autoclass:: Subject()
.. autoclass:: Body()
For presence’
-------------
.. autoclass:: Status()
Exceptions
==========
.. autoclass:: PayloadError
.. autoclass:: PayloadParsingError
.. autoclass:: UnknownIQPayload
Module Level Constants
======================
.. autodata:: RANDOM_ID_BYTES
"""
import random
import warnings
from . import xso, errors, structs
from .utils import namespaces, to_nmtoken
#: The number of bytes of randomness used when generating stanza IDs.
RANDOM_ID_BYTES = 120 // 8
def _safe_format_attr(obj, attr_name):
try:
value = getattr(obj, attr_name)
except AttributeError as exc:
msg = str(exc)
if msg.startswith("attribute value is incomplete"):
return ""
else:
return ""
if isinstance(value, structs.JID):
return "'{!s}'".format(value)
return repr(value)
class StanzaError(Exception):
"""
Base class for exceptions raised when stanzas cannot be processed.
.. attribute:: partial_obj
The :class:`StanzaBase` instance which has not been parsed completely.
There are no guarantees about any attributes. This is, if at all, only
useful for logging.
.. attribute:: ev_args
The XSO parsing event arguments which caused the parsing to fail.
.. attribute:: descriptor
The descriptor whose parsing function raised the exception.
"""
def __init__(self, msg, partial_obj, ev_args, descriptor):
super().__init__(msg)
self.ev_args = ev_args
self.partial_obj = partial_obj
self.descriptor = descriptor
class PayloadError(StanzaError):
"""
Base class for exceptions raised when stanza payloads cannot be processed.
This is a subclass of :class:`StanzaError`. :attr:`partial_obj` has the
additional guarantee that the attributes :attr:`StanzaBase.from_`,
:attr:`StanzaBase.to`, :attr:`StanzaBase.type_` and :attr:`StanzaBase.id_`
are already parsed completely.
"""
class PayloadParsingError(PayloadError):
"""
A constraint of a sub-object was not fulfilled and the stanza being
processed is illegal. The partially parsed stanza object is provided in
:attr:`~PayloadError.partial_obj`.
This is a subclass of :class:`PayloadError`.
"""
def __init__(self, partial_obj, ev_args, descriptor):
super().__init__(
"parsing of payload {} failed".format(
xso.tag_to_str((ev_args[0], ev_args[1]))),
partial_obj,
ev_args,
descriptor)
class UnknownIQPayload(PayloadError):
"""
The payload of an IQ object is unknown. The partial object with attributes
but without payload is available through :attr:`~PayloadError.partial_obj`.
"""
def __init__(self, partial_obj, ev_args, descriptor):
super().__init__(
"unknown payload {} on iq".format(
xso.tag_to_str((ev_args[0], ev_args[1]))),
partial_obj,
ev_args,
descriptor
)
class Error(xso.XSO):
"""
An XMPP stanza error. The keyword arguments can be used to initialize the
attributes of the :class:`Error`.
:param condition: The error condition as enumeration member or XSO.
:type condition: :class:`aioxmpp.ErrorCondition` or
:class:`aioxmpp.xso.XSO`
:param type_: The type of the error
:type type_: :class:`aioxmpp.ErrorType`
:param text: The optional error text
:type text: :class:`str` or :data:`None`
.. attribute:: type_
The type attribute of the stanza. The allowed values are enumerated in
:class:`~.ErrorType`.
.. versionchanged:: 0.7
Starting with 0.7, the enumeration :class:`~.ErrorType` is
used. Before, strings equal to the XML attribute value character data
were used (``"cancel"``, ``"auth"``, and so on).
As of 0.7, setting the string equivalents is still supported.
However, reading from the attribute always returns the corresponding
enumeration members (which still compare equal to their string
equivalents).
.. deprecated:: 0.7
The use of the aforementioned string values is deprecated and will
lead to :exc:`TypeError` and/or :exc:`ValueError` being raised when
they are written to this attribute. See the Changelog for
:ref:`api-changelog-0.7` for further details on how to upgrade your
code efficiently.
.. attribute:: condition
The standard defined condition which triggered the error. Possible
values can be determined by looking at the RFC or the source.
This is a member of the :class:`aioxmpp.ErrorCondition` enumeration.
.. versionchanged:: 0.10
Starting with 0.10, the enumeration :class:`aioxmpp.ErrorCondition`
is used. Before, tuples equal to the tags of the child elements were
used (e.g. ``(namespaces.stanzas, "bad-request")``).
As of 0.10, setting the tuple equivalents is still supported.
However, reading from the attribute always returns the corresponding
enumeration members (which still compare equal to their tuple
equivalents).
.. deprecated:: 0.10
The use of the aforementioned tuple values is deprecated and will
lead to :exc:`TypeError` and/or :exc:`ValueError` being raised when
they are written to this attribute. See the changelog for notes on
the transition.
.. attribute:: condition_obj
An XSO object representing the child element representing the
:rfc:`6120` defined error condition.
.. versionadded:: 0.10
.. attribute:: text
The descriptive error text which is part of the error stanza, if any
(otherwise :data:`None`).
Any child elements unknown to the XSO are dropped. This is to support
application-specific conditions used by other applications. To register
your own use :meth:`.xso.XSO.register_child` on
:attr:`application_condition`:
.. attribute:: application_condition
Optional child :class:`~aioxmpp.xso.XSO` which describes the error
condition in more application specific detail.
To register a class as application condition, use:
.. automethod:: as_application_condition
Conversion to and from exceptions is supported with the following methods:
.. automethod:: to_exception
.. automethod:: from_exception
"""
TAG = (namespaces.client, "error")
DECLARE_NS = {}
EXCEPTION_CLS_MAP = {
structs.ErrorType.MODIFY: errors.XMPPModifyError,
structs.ErrorType.CANCEL: errors.XMPPCancelError,
structs.ErrorType.AUTH: errors.XMPPAuthError,
structs.ErrorType.WAIT: errors.XMPPWaitError,
structs.ErrorType.CONTINUE: errors.XMPPContinueError,
}
UNKNOWN_CHILD_POLICY = xso.UnknownChildPolicy.DROP
UNKNOWN_ATTR_POLICY = xso.UnknownAttrPolicy.DROP
type_ = xso.Attr(
tag="type",
type_=xso.EnumCDataType(
structs.ErrorType,
allow_coerce=True,
deprecate_coerce=True,
),
)
text = xso.ChildText(
tag=(namespaces.stanzas, "text"),
attr_policy=xso.UnknownAttrPolicy.DROP,
default=None,
declare_prefix=None
)
condition_obj = xso.Child(
[member.xso_class for member in errors.ErrorCondition],
required=True,
)
application_condition = xso.Child([], required=False)
def __init__(self,
condition=errors.ErrorCondition.UNDEFINED_CONDITION,
type_=structs.ErrorType.CANCEL,
text=None):
super().__init__()
if not isinstance(condition, (errors.ErrorCondition, xso.XSO)):
condition = errors.ErrorCondition(condition)
warnings.warn(
"as of aioxmpp 1.0, error conditions must be members of the "
"aioxmpp.ErrorCondition enumeration",
DeprecationWarning,
stacklevel=2,
)
self.condition_obj = condition.to_xso()
self.type_ = type_
self.text = text
@property
def condition(self):
return self.condition_obj.enum_member
@condition.setter
def condition(self, value):
if not isinstance(value, errors.ErrorCondition):
value = errors.ErrorCondition(value)
warnings.warn(
"as of aioxmpp 1.0, error conditions must be members of the "
"aioxmpp.ErrorCondition enumeration",
DeprecationWarning,
stacklevel=2,
)
if self.condition == value:
return
self.condition_obj = value.xso_class()
@classmethod
def from_exception(cls, exc):
"""
Construct a new :class:`Error` payload from the attributes of the
exception.
:param exc: The exception to convert
:type exc: :class:`aioxmpp.errors.XMPPError`
:result: Newly constructed error payload
:rtype: :class:`Error`
.. versionchanged:: 0.10
The :attr:`aioxmpp.XMPPError.application_defined_condition` is now
taken over into the result.
"""
result = cls(
condition=exc.condition,
type_=exc.TYPE,
text=exc.text
)
result.application_condition = exc.application_defined_condition
return result
def to_exception(self):
"""
Convert the error payload to a :class:`~aioxmpp.errors.XMPPError`
subclass.
:result: Newly constructed exception
:rtype: :class:`aioxmpp.errors.XMPPError`
The exact type of the result depends on the :attr:`type_` (see
:class:`~aioxmpp.errors.XMPPError` about the existing subclasses).
The :attr:`condition_obj`, :attr:`text` and
:attr:`application_condition` are transferred to the respective
attributes of the :class:`~aioxmpp.errors.XMPPError`.
"""
if hasattr(self.application_condition, "to_exception"):
result = self.application_condition.to_exception(self.type_)
if isinstance(result, Exception):
return result
return self.EXCEPTION_CLS_MAP[self.type_](
condition=self.condition_obj,
text=self.text,
application_defined_condition=self.application_condition,
)
@classmethod
def as_application_condition(cls, other_cls):
"""
Register `other_cls` as child class for the
:attr:`application_condition` attribute. Doing so will allows the class
to be parsed instead of being discarded.
.. seealso::
:func:`make_application_error` --- creates and automatically
registers a new application error condition.
"""
cls.register_child(cls.application_condition, other_cls)
return other_cls
def __repr__(self):
payload = ""
if self.text:
payload = " text={!r}".format(self.text)
return "<{} type={!r}{}>".format(
self.condition.value[1],
self.type_,
payload)
class StanzaBase(xso.XSO):
"""
Base for all stanza classes. Usually, you will use the derived classes:
.. autosummary::
:nosignatures:
Message
Presence
IQ
However, some common attributes are defined in this base class:
.. attribute:: from_
The :class:`~aioxmpp.JID` of the sending entity.
.. attribute:: to
The :class:`~aioxmpp.JID` of the receiving entity.
.. attribute:: lang
The ``xml:lang`` value as :class:`~aioxmpp.structs.LanguageTag`.
.. attribute:: error
Either :data:`None` or a :class:`Error` instance.
.. note::
The :attr:`id_` attribute is not defined in :class:`StanzaBase` as
different stanza classes have different requirements with respect to
presence of that attribute.
In addition to these attributes, common methods needed are also provided:
.. automethod:: autoset_id
.. automethod:: make_error
"""
DECLARE_NS = {}
from_ = xso.Attr(
tag="from",
type_=xso.JID(),
default=None)
to = xso.Attr(
tag="to",
type_=xso.JID(),
default=None)
lang = xso.LangAttr(
tag=(namespaces.xml, "lang")
)
error = xso.Child([Error])
def __init__(self, *, from_=None, to=None, id_=None):
super().__init__()
if from_ is not None:
self.from_ = from_
if to is not None:
self.to = to
if id_ is not None:
self.id_ = id_
def autoset_id(self):
"""
If the :attr:`id_` already has a non-false (false is also the empty
string!) value, this method is a no-op.
Otherwise, the :attr:`id_` attribute is filled with
:data:`RANDOM_ID_BYTES` of random data, encoded by
:func:`aioxmpp.utils.to_nmtoken`.
.. note::
This method only works on subclasses of :class:`StanzaBase` which
define the :attr:`id_` attribute.
"""
try:
self.id_
except AttributeError:
pass
else:
if self.id_:
return
self.id_ = to_nmtoken(random.getrandbits(8*RANDOM_ID_BYTES))
def _make_reply(self, type_):
obj = type(self)(type_)
obj.from_ = self.to
obj.to = self.from_
obj.id_ = self.id_
return obj
def make_error(self, error):
"""
Create a new instance of this stanza (this directly uses
``type(self)``, so also works for subclasses without extra care) which
has the given `error` value set as :attr:`error`.
In addition, the :attr:`id_`, :attr:`from_` and :attr:`to` values are
transferred from the original (with from and to being swapped). Also,
the :attr:`type_` is set to ``"error"``.
"""
obj = type(self)(
from_=self.to,
to=self.from_,
# because flat is better than nested (sarcasm)
type_=type(self).type_.type_.enum_class.ERROR,
)
obj.id_ = self.id_
obj.error = error
return obj
def xso_error_handler(self, descriptor, ev_args, exc_info):
raise StanzaError(
"failed to parse stanza",
self,
ev_args,
descriptor
)
class Thread(xso.XSO):
"""
Threading information, consisting of a thread identifier and an optional
parent thread identifier.
.. attribute:: identifier
Identifier of the thread
.. attribute:: parent
:data:`None` or the identifier of the parent thread.
"""
TAG = (namespaces.client, "thread")
identifier = xso.Text(
validator=xso.Nmtoken(),
validate=xso.ValidateMode.FROM_CODE)
parent = xso.Attr(
tag="parent",
validator=xso.Nmtoken(),
validate=xso.ValidateMode.FROM_CODE,
default=None
)
class Body(xso.AbstractTextChild):
"""
The textual body of a :class:`Message` stanza.
While it might seem intuitive to refer to the body using a
:class:`~.xso.ChildText` descriptor, the fact that there might be multiple
texts for different languages justifies the use of a separate class.
.. attribute:: lang
The ``xml:lang`` of this body part, as :class:`~.structs.LanguageTag`.
.. attribute:: text
The textual content of the body.
"""
TAG = (namespaces.client, "body")
class Subject(xso.AbstractTextChild):
"""
The subject of a :class:`Message` stanza.
While it might seem intuitive to refer to the subject using a
:class:`~.xso.ChildText` descriptor, the fact that there might be multiple
texts for different languages justifies the use of a separate class.
.. attribute:: lang
The ``xml:lang`` of this subject part, as
:class:`~.structs.LanguageTag`.
.. attribute:: text
The textual content of the subject
"""
TAG = (namespaces.client, "subject")
class Message(StanzaBase):
"""
An XMPP message stanza. The keyword arguments can be used to initialize the
attributes of the :class:`Message`.
.. attribute:: id_
The optional ID of the stanza.
.. attribute:: type_
The type attribute of the stanza. The allowed values are enumerated in
:class:`~.MessageType`.
.. versionchanged:: 0.7
Starting with 0.7, the enumeration :class:`~.MessageType` is
used. Before, strings equal to the XML attribute value character data
were used (``"chat"``, ``"headline"``, and so on).
As of 0.7, setting the string equivalents is still supported.
However, reading from the attribute always returns the corresponding
enumeration members (which still compare equal to their string
equivalents).
.. deprecated:: 0.7
The use of the aforementioned string values is deprecated and will
lead to :exc:`TypeError` and/or :exc:`ValueError` being raised when
they are written to this attribute. See the Changelog for
:ref:`api-changelog-0.7` for further details on how to upgrade your
code efficiently.
.. attribute:: body
A :class:`~.structs.LanguageMap` mapping the languages of the different
body elements to their text.
.. versionchanged:: 0.5
Before 0.5, this was a :class:`~aioxmpp.xso.model.XSOList`.
.. attribute:: subject
A :class:`~.structs.LanguageMap` mapping the languages of the different
subject elements to their text.
.. versionchanged:: 0.5
Before 0.5, this was a :class:`~aioxmpp.xso.model.XSOList`.
.. attribute:: thread
A :class:`Thread` instance representing the threading information
attached to the message or :data:`None` if no threading information is
attached.
Note that some attributes are inherited from :class:`StanzaBase`:
========================= =======================================
:attr:`~StanzaBase.from_` sender :class:`~aioxmpp.JID`
:attr:`~StanzaBase.to` recipient :class:`~aioxmpp.JID`
:attr:`~StanzaBase.lang` ``xml:lang`` value
:attr:`~StanzaBase.error` :class:`Error` instance
========================= =======================================
.. automethod:: make_reply
"""
TAG = (namespaces.client, "message")
UNKNOWN_CHILD_POLICY = xso.UnknownChildPolicy.DROP
id_ = xso.Attr(tag="id", default=None)
type_ = xso.Attr(
tag="type",
type_=xso.EnumCDataType(
structs.MessageType,
allow_coerce=True,
deprecate_coerce=True,
# changing the following breaks stanza handling; StanzaStream
# relies on the meta-properties of the enumerations (is_request and
# such).
allow_unknown=False,
accept_unknown=False,
),
default=structs.MessageType.NORMAL,
erroneous_as_absent=True,
)
body = xso.ChildTextMap(Body)
subject = xso.ChildTextMap(Subject)
thread = xso.Child([Thread])
def __init__(self, type_, **kwargs):
super().__init__(**kwargs)
self.type_ = type_
def make_reply(self):
"""
Create a reply for the message. The :attr:`id_` attribute is cleared in
the reply. The :attr:`from_` and :attr:`to` are swapped and the
:attr:`type_` attribute is the same as the one of the original
message.
The new :class:`Message` object is returned.
"""
obj = super()._make_reply(self.type_)
obj.id_ = None
return obj
def __repr__(self):
return "".format(
_safe_format_attr(self, "from_"),
_safe_format_attr(self, "to"),
_safe_format_attr(self, "id_"),
_safe_format_attr(self, "type_"),
)
class Status(xso.AbstractTextChild):
"""
The status of a :class:`Presence` stanza.
While it might seem intuitive to refer to the status using a
:class:`~.xso.ChildText` descriptor, the fact that there might be multiple
texts for different languages justifies the use of a separate class.
.. attribute:: lang
The ``xml:lang`` of this status part, as :class:`~.structs.LanguageTag`.
.. attribute:: text
The textual content of the status
"""
TAG = (namespaces.client, "status")
class Presence(StanzaBase):
"""
An XMPP presence stanza. The keyword arguments can be used to initialize
the attributes of the :class:`Presence`.
.. attribute:: id_
The optional ID of the stanza.
.. attribute:: type_
The type attribute of the stanza. The allowed values are enumerated in
:class:`~.PresenceType`.
.. versionchanged:: 0.7
Starting with 0.7, the enumeration :class:`~.PresenceType` is
used. Before, strings equal to the XML attribute value character data
were used (``"probe"``, ``"unavailable"``, and so on, as well as
:data:`None` to indicate the absence of the attribute and thus
"available" presence).
As of 0.7, setting the string equivalents and :data:`None` is still
supported. However, reading from the attribute always returns the
corresponding enumeration members (which still compare equal to their
string equivalents).
.. deprecated:: 0.7
The use of the aforementioned string values (and :data:`None`) is
deprecated and will lead to :exc:`TypeError` and/or :exc:`ValueError`
being raised when they are written to this attribute. See the
Changelog for :ref:`api-changelog-0.7` for further details on how to
upgrade your code efficiently.
.. attribute:: show
The ``show`` value of the stanza, or :data:`None` if no ``show`` element
is present.
.. attribute:: priority
The ``priority`` value of the presence. The default here is ``0`` and
corresponds to an absent ``priority`` element.
.. attribute:: status
A :class:`~.structs.LanguageMap` mapping the languages of the different
status elements to their text.
.. versionchanged:: 0.5
Before 0.5, this was a :class:`~aioxmpp.xso.model.XSOList`.
Note that some attributes are inherited from :class:`StanzaBase`:
========================= =======================================
:attr:`~StanzaBase.from_` sender :class:`~aioxmpp.JID`
:attr:`~StanzaBase.to` recipient :class:`~aioxmpp.JID`
:attr:`~StanzaBase.lang` ``xml:lang`` value
:attr:`~StanzaBase.error` :class:`Error` instance
========================= =======================================
"""
TAG = (namespaces.client, "presence")
id_ = xso.Attr(tag="id", default=None)
type_ = xso.Attr(
tag="type",
type_=xso.EnumCDataType(
structs.PresenceType,
allow_coerce=True,
deprecate_coerce=True,
# changing the following breaks stanza handling; StanzaStream
# relies on the meta-properties of the enumerations (is_request and
# such).
allow_unknown=False,
accept_unknown=False,
),
default=structs.PresenceType.AVAILABLE,
)
show = xso.ChildText(
tag=(namespaces.client, "show"),
type_=xso.EnumCDataType(
structs.PresenceShow,
allow_coerce=True,
deprecate_coerce=True,
allow_unknown=False,
accept_unknown=False,
),
default=structs.PresenceShow.NONE,
erroneous_as_absent=True,
)
status = xso.ChildTextMap(Status)
priority = xso.ChildText(
tag=(namespaces.client, "priority"),
type_=xso.Integer(),
default=0
)
unhandled_children = xso.Collector()
def __init__(self, *,
type_=structs.PresenceType.AVAILABLE,
show=structs.PresenceShow.NONE, **kwargs):
super().__init__(**kwargs)
self.type_ = type_
self.show = show
def __repr__(self):
return "".format(
_safe_format_attr(self, "from_"),
_safe_format_attr(self, "to"),
_safe_format_attr(self, "id_"),
_safe_format_attr(self, "type_"),
)
class IQ(StanzaBase):
"""
An XMPP IQ stanza. The keyword arguments can be used to initialize the
attributes of the :class:`IQ`.
.. attribute:: id_
The optional ID of the stanza.
.. attribute:: type_
The type attribute of the stanza. The allowed values are enumerated in
:class:`~.IQType`.
.. versionchanged:: 0.7
Starting with 0.7, the enumeration :class:`~.IQType` is used.
Before, strings equal to the XML attribute value character data were
used (``"get"``, ``"set"``, and so on).
As of 0.7, setting the string equivalents is still supported.
However, reading from the attribute always returns the corresponding
enumeration members (which still compare equal to their string
equivalents).
.. deprecated:: 0.7
The use of the aforementioned string values is deprecated and will
lead to :exc:`TypeError` and/or :exc:`ValueError` being raised when
they are written to this attribute. See the Changelog for
:ref:`api-changelog-0.7` for further details on how to upgrade your
code efficiently.
.. attribute:: payload
An XSO which forms the payload of the IQ stanza.
Note that some attributes are inherited from :class:`StanzaBase`:
========================= =======================================
:attr:`~StanzaBase.from_` sender :class:`~aioxmpp.JID`
:attr:`~StanzaBase.to` recipient :class:`~aioxmpp.JID`
:attr:`~StanzaBase.lang` ``xml:lang`` value
:attr:`~StanzaBase.error` :class:`Error` instance
========================= =======================================
New payload classes can be registered using:
.. automethod:: as_payload_class
"""
TAG = (namespaces.client, "iq")
UNKNOWN_CHILD_POLICY = xso.UnknownChildPolicy.FAIL
id_ = xso.Attr(tag="id")
type_ = xso.Attr(
tag="type",
type_=xso.EnumCDataType(
structs.IQType,
allow_coerce=True,
deprecate_coerce=True,
# changing the following breaks stanza handling; StanzaStream
# relies on the meta-properties of the enumerations (is_request and
# such).
allow_unknown=False,
accept_unknown=False,
)
)
payload = xso.Child([], strict=True)
def __init__(self, type_, *, payload=None, error=None, **kwargs):
super().__init__(**kwargs)
self.type_ = type_
self.payload = payload
self.error = error
def _validate(self):
try:
self.id_
except AttributeError:
raise ValueError("IQ requires ID") from None
if self.type_ == structs.IQType.ERROR and self.error is None:
raise ValueError("IQ with type='error' requires error payload")
super().validate()
def validate(self):
try:
self._validate()
except Exception:
raise StanzaError(
"invalid IQ stanza",
self,
None,
None,
)
def make_reply(self, type_):
if not self.type_.is_request:
raise ValueError("make_reply requires request IQ")
obj = super()._make_reply(type_)
return obj
def xso_error_handler(self, descriptor, ev_args, exc_info):
# raise a specific error if the payload failed to parse
if descriptor == IQ.payload.xq_descriptor:
raise PayloadParsingError(self, ev_args, descriptor)
elif descriptor is None:
raise UnknownIQPayload(self, ev_args, descriptor)
return super().xso_error_handler(descriptor, ev_args, exc_info)
def __repr__(self):
payload = ""
try:
if self.type_.is_error:
payload = " error={!r}".format(self.error)
elif self.payload:
payload = " data={!r}".format(self.payload)
except AttributeError:
payload = " error={!r} data={!r}".format(
self.error,
self.payload
)
return "".format(
_safe_format_attr(self, "from_"),
_safe_format_attr(self, "to"),
_safe_format_attr(self, "id_"),
_safe_format_attr(self, "type_"),
payload)
@classmethod
def as_payload_class(cls, other_cls):
"""
Register `other_cls` as possible :class:`IQ` :attr:`payload`. Doing so
is required in order to receive IQs with such payload.
"""
cls.register_child(cls.payload, other_cls)
return other_cls
def make_application_error(name, tag):
"""
Create and return a **class** inheriting from :class:`.xso.XSO`. The
:attr:`.xso.XSO.TAG` is set to `tag` and the class’ name will be `name`.
In addition, the class is automatically registered with
:attr:`.Error.application_condition` using
:meth:`~.Error.as_application_condition`.
Keep in mind that if you subclass the class returned by this function, the
subclass is not registered with :class:`.Error`. In addition, if you do not
override the :attr:`~.xso.XSO.TAG`, you will not be able to register
the subclass as application defined condition as it has the same tag as the
class returned by this function, which has already been registered as
application condition.
"""
cls = type(xso.XSO)(name, (xso.XSO,), {
"TAG": tag,
})
Error.as_application_condition(cls)
return cls
aioxmpp/statemachine.py 0000664 0000000 0000000 00000014336 14160146213 0015557 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: statemachine.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.statemachine` -- Utils for implementing a state machine
######################################################################
.. autoclass:: OrderedStateMachine
.. autoclass:: OrderedStateSkipped
"""
import asyncio
class OrderedStateSkipped(ValueError):
"""
This exception signals that a state has been skipped in a
:class:`OrderedStateMachine` and cannot be waited for anymore.
.. attribute:: skipped_state
The state which a routine attempted to wait for and which cannot be
reached by the state machine anymore.
"""
def __init__(self, skipped_state):
super().__init__("state {} has been skipped".format(skipped_state))
self.skipped_state = skipped_state
class OrderedStateMachine:
"""
:class:`OrderedStateMachine` provides facilities to implement a state
machine. Besides storing the state, it provides coroutines which allow to
wait for a specific state.
The state machine uses `initial_state` as initial state. States used by
:class:`OrderedStateMachine` must be ordered; a sanity check is performed
by checking if the `initial_state` is less than itself. If that check
fails, :class:`TypeError` is raised.
Reading and manipulating the state:
.. autoattribute:: state
.. automethod:: rewind
Waiting for specific states:
.. automethod:: wait_for
.. automethod:: wait_for_at_least
"""
def __init__(self, initial_state, *, loop=None):
try:
initial_state < initial_state
except (TypeError, AttributeError):
raise TypeError("states must be ordered")
self._state = initial_state
self._least_waiters = []
self._exact_waiters = []
self.loop = loop if loop is not None else asyncio.get_event_loop()
@property
def state(self):
"""
The current state of the state machine. Writing to this attribute
advances the state of the state machine.
Attempting to change the state to a state which is *less* than the
current state will result in a :class:`ValueError` exception; an
:class:`OrderedStateMachine` can only move forwards.
Any coroutines waiting for a specific state to be reached will be woken
up appropriately, see the specific methods for details.
"""
return self._state
@state.setter
def state(self, new_state):
if new_state < self._state:
raise ValueError("cannot rewind OrderedStateMachine "
"({} < {})".format(
new_state, self._state))
self._state = new_state
new_waiters = []
for least_state, fut in self._least_waiters:
if fut.done():
continue
if not (new_state < least_state):
fut.set_result(None)
continue
new_waiters.append((least_state, fut))
self._least_waiters[:] = new_waiters
new_waiters = []
for expected_state, fut in self._exact_waiters:
if fut.done():
continue
if expected_state == new_state:
fut.set_result(None)
continue
if expected_state < new_state:
fut.set_exception(OrderedStateSkipped(expected_state))
continue
new_waiters.append((expected_state, fut))
self._exact_waiters[:] = new_waiters
def rewind(self, new_state):
"""
Rewind can be used as an exceptional way to roll back the state of a
:class:`OrderedStateMachine`.
Rewinding is not the usual use case for an
:class:`OrderedStateMachine`. Usually, if the current state `A` is
greater than any given state `B`, it is assumed that state `B` cannot
be reached anymore (which makes :meth:`wait_for` raise).
It may make sense to go backwards though, and in cases where the
ability to go backwards is sensible even if routines which previously
attempted to wait for the state you are going backwards to failed,
using a :class:`OrderedStateMachine` is still a good idea.
"""
if new_state > self._state:
raise ValueError("cannot forward using rewind "
"({} > {})".format(new_state, self._state))
self._state = new_state
async def wait_for(self, new_state):
"""
Wait for an exact state `new_state` to be reached by the state
machine.
If the state is skipped, that is, if a state which is greater than
`new_state` is written to :attr:`state`, the coroutine raises
:class:`OrderedStateSkipped` exception as it is not possible anymore
that it can return successfully (see :attr:`state`).
"""
if self._state == new_state:
return
if self._state > new_state:
raise OrderedStateSkipped(new_state)
fut = asyncio.Future(loop=self.loop)
self._exact_waiters.append((new_state, fut))
await fut
async def wait_for_at_least(self, new_state):
"""
Wait for a state to be entered which is greater than or equal to
`new_state` and return.
"""
if not (self._state < new_state):
return
fut = asyncio.Future(loop=self.loop)
self._least_waiters.append((new_state, fut))
await fut
aioxmpp/stream.py 0000664 0000000 0000000 00000305256 14160146213 0014411 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: stream.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.stream` --- Stanza stream
########################################
The stanza stream is the layer of abstraction above the XML stream. It deals
with sending and receiving stream-level elements, mainly stanzas. It also
handles stream liveness and stream management.
It provides ways to track stanzas on their way to the remote, as far as that is
possible.
.. _aioxmpp.stream.General Information:
General information
===================
.. _aioxmpp.stream.General Information.Timeouts:
Timeouts / Stream Aliveness checks
----------------------------------
The :class:`StanzaStream` relies on the :class:`XMLStream` dead time aliveness
monitoring (see also :attr:`~.XMLStream.deadtime_soft_limit`) to detect a
broken stream.
The limits can be configured with the two attributes
:attr:`~.StanzaStream.soft_timeout` and :attr:`~.StanzaStream.round_trip_time`.
When :attr:`~.StanzaStream.soft_timeout` elapses after the last bit of data
received from the server, the :class:`StanzaStream` issues a ping. The server
then has one :attr:`~.StanzaStream.round_trip_time` worth of time to answer.
If this does not happen, the :class:`XMLStream` will be terminated by the
aliveness monitor and normal handling of a broken connection takes over.
.. _aioxmpp.stream.General Information.Filters:
Stanza Filters
--------------
Stanza filters allow to hook into the stanza sending/reception pipeline
after/before the application sees the stanza.
Inbound stanza filters
~~~~~~~~~~~~~~~~~~~~~~
Inbound stanza filters allow to hook into the stanza processing by replacing,
modifying or otherwise processing stanza contents *before* the usual handlers
for that stanza are invoked. With inbound stanza filters, there are no
restrictions as to what processing may take place on a stanza, as no one but
the stream may have references to its contents.
.. warning::
Raising an exception from within a stanza filter kills the stream.
Note that if a filter function drops an incoming stanza (by returning
:data:`None`), it **must** ensure that the client still behaves RFC
compliant. The inbound stanza filters are found here:
* :attr:`~.StanzaStream.service_inbound_message_filter`
* :attr:`~.StanzaStream.service_inbound_presence_filter`
* :attr:`~.StanzaStream.app_inbound_message_filter`
* :attr:`~.StanzaStream.app_inbound_presence_filter`
Outbound stanza filters
~~~~~~~~~~~~~~~~~~~~~~~
Outbound stanza filters work similar to inbound stanza filters, but due to
their location in the processing chain and possible interactions with senders
of stanzas, there are some things to consider:
* Per convention, a outbound stanza filter **must not** modify any child
elements which are already present in the stanza when it receives the
stanza.
It may however add new child elements or remove existing child elements,
as well as copying and *then* modifying existing child elements.
* If the stanza filter replaces the stanza, it is responsible for making
sure that the new stanza has appropriate
:attr:`~.stanza.StanzaBase.from_`, :attr:`~.stanza.StanzaBase.to` and
:attr:`~.stanza.StanzaBase.id` values. There are no checks to enforce
this, because error handling at this point is peculiar. The stanzas will
be sent as-is.
* Similar to inbound filters, it is the responsibility of the filters that
if stanzas are dropped, the client still behaves RFC-compliant.
Now that you have been warned, here are the attributes for accessing the
outbound filter chains. These otherwise work exactly like their inbound
counterparts, but service filters run *after* application filters on
outbound processing.
* :attr:`~.StanzaStream.service_outbound_message_filter`
* :attr:`~.StanzaStream.service_outbound_presence_filter`
* :attr:`~.StanzaStream.app_outbound_message_filter`
* :attr:`~.StanzaStream.app_outbound_presence_filter`
When to use stanza filters?
~~~~~~~~~~~~~~~~~~~~~~~~~~~
In general, applications will rarely need them. However, services may make
profitable use of them, and it is a convenient way for them to inspect or
modify incoming or outgoing stanzas before any normally registered handler
processes them.
In general, whenever you do something which *supplements* the use of the stanza
with respect to the RFC but does not fulfill the original intent of the stanza,
it is advisable to use a filter instead of a callback on the actual stanza.
Vice versa, if you were to develop a service which manages presence
subscriptions, it would be more correct to use
:meth:`register_presence_callback`; this prevents other services which try
to do the same from conflicting with you. You would then provide callbacks
to the application to let it learn about presence subscriptions.
Stanza Stream class
===================
This section features the complete documentation of the (rather important and
complex) :class:`StanzaStream`. Some more general information has been moved to
the previous section (:ref:`aioxmpp.stream.General Information`) to make it
easier to read and find.
.. autoclass:: StanzaStream
Context managers
================
The following context managers can be used together with :class:`StanzaStream`
instances and the filters available on them.
.. autofunction:: iq_handler
.. autofunction:: message_handler
.. autofunction:: presence_handler
.. autofunction:: stanza_filter
Low-level stanza tracking
=========================
The following classes are used to track stanzas in the XML stream to the
server. This is independent of things like :xep:`Message Delivery Receipts
<0184>` (for which services are provided at :mod:`aioxmpp.tracking`); it
only provides tracking to the remote server and even that only if stream
management is used. Otherwise, it only provides tracking in the :mod:`aioxmpp`
internal queues.
.. autoclass:: StanzaToken
.. autoclass:: StanzaState
Filters
=======
The service-level filters used by the :class:`StanzaStream` use
:class:`~.callbacks.Filter`. The application-level filters are using the
following class:
.. autoclass:: AppFilter
Exceptions
==========
.. autoclass:: DestructionRequested
"""
import asyncio
import contextlib
import functools
import logging
import warnings
from datetime import timedelta
from enum import Enum
from . import (
stanza,
stanza as stanza_,
errors,
custom_queue,
nonza,
callbacks,
protocol,
structs,
ping,
)
class AppFilter(callbacks.Filter):
"""
A specialized :class:`Filter` version. The only difference is in the
handling of the `order` argument to :meth:`register`:
.. automethod:: register
"""
def register(self, func, order=0):
"""
This method works exactly like :meth:`Filter.register`, but `order` has
a default value of ``0``.
"""
return super().register(func, order)
class PingEventType(Enum):
SEND_OPPORTUNISTIC = 0
SEND_NOW = 1
TIMEOUT = 2
class DestructionRequested(ConnectionError):
"""
Subclass of :class:`ConnectionError` indicating that the destruction of the
stream was requested by the user, directly or indirectly.
"""
class StanzaState(Enum):
"""
The various states an outgoing stanza can have.
.. attribute:: ACTIVE
The stanza has just been enqueued for sending and has not been taken
care of by the StanzaStream yet.
.. attribute:: SENT
The stanza has been sent over a stream with Stream Management enabled,
but not acked by the remote yet.
.. attribute:: ACKED
The stanza has been sent over a stream with Stream Management enabled
and has been acked by the remote.
This is a final state.
.. attribute:: SENT_WITHOUT_SM
The stanza has been sent over a stream without Stream Management enabled
or has been sent over a stream with Stream Management enabled, but for
which resumption has failed before the stanza has been acked.
This is a final state.
.. attribute:: ABORTED
The stanza has been retracted before it left the active queue.
This is a final state.
.. attribute:: DROPPED
The stanza has been dropped by one of the filters configured in the
:class:`StanzaStream`.
This is a final state.
.. attribute:: DISCONNECTED
The stream has been stopped (without SM) or closed before the stanza was
sent.
This is a final state.
.. attribute:: FAILED
It was attempted to send the stanza, but it failed to serialise to
valid XML or another non-fatal transport error occurred.
This is a final state.
.. versionadded:: 0.9
"""
ACTIVE = 0
SENT = 1
ACKED = 2
SENT_WITHOUT_SM = 3
ABORTED = 4
DROPPED = 5
DISCONNECTED = 6
FAILED = 7
class StanzaErrorAwareListener:
def __init__(self, forward_to):
self._forward_to = forward_to
def data(self, stanza_obj):
if stanza_obj.type_.is_error:
return self._forward_to.error(
stanza_obj.error.to_exception()
)
return self._forward_to.data(stanza_obj)
def error(self, exc):
return self._forward_to.error(exc)
def is_valid(self):
return self._forward_to.is_valid()
class StanzaToken:
"""
A token to follow the processing of a `stanza`.
`on_state_change` may be a function which will be called with the token and
the new :class:`StanzaState` whenever the state of the token changes.
.. versionadded:: 0.8
Stanza tokens are :term:`awaitable`.
.. describe:: await token
.. describe:: yield from token
Wait until the stanza is either sent or failed to sent.
.. warning::
This only works with Python 3.5 or newer.
:raises ConnectionError: if the stanza enters
:attr:`~.StanzaState.DISCONNECTED` state.
:raises RuntimeError: if the stanza enters :attr:`~.StanzaState.ABORTED`
or :attr:`~.StanzaState.DROPPED` state.
:raises Exception: re-raised if the stanza token fails to serialise or
another transient transport problem occurs.
:return: :data:`None`
If a coroutine awaiting a token is cancelled, the token is aborted. Use
:func:`asyncio.shield` to prevent this.
.. warning::
This is no guarantee that the recipient received the stanza. Without
stream management, it can not even guaranteed that the server has
seen the stanza.
This is primarily useful as a synchronisation primitive between the
sending of a stanza and another stream operation, such as closing the
stream.
.. note::
Exceptions sent to the stanza token (when it enters
:attr:`StanzaState.FAILED`) are only available by awaiting the token,
not via the callback.
.. autoattribute:: state
.. automethod:: abort
"""
__slots__ = ("stanza", "_state", "on_state_change", "_sent_future",
"_state_exception")
def __init__(self, stanza, *, on_state_change=None):
self.stanza = stanza
self._state = StanzaState.ACTIVE
self._state_exception = None
self._sent_future = None
self.on_state_change = on_state_change
@property
def state(self):
"""
The current :class:`StanzaState` of the token. Tokens are created with
:attr:`StanzaState.ACTIVE`.
"""
return self._state
@property
def future(self):
if self._sent_future is None:
self._sent_future = asyncio.Future()
self._update_future()
return self._sent_future
def _update_future(self):
if self._sent_future.done():
return
if self._state == StanzaState.DISCONNECTED:
self._sent_future.set_exception(ConnectionError("disconnected"))
elif self._state == StanzaState.DROPPED:
self._sent_future.set_exception(
RuntimeError("stanza dropped by filter")
)
elif self._state == StanzaState.ABORTED:
self._sent_future.set_exception(RuntimeError("stanza aborted"))
elif self._state == StanzaState.FAILED:
self._sent_future.set_exception(
self._state_exception or
ValueError("failed to send stanza for unknown local reasons")
)
elif (self._state == StanzaState.SENT_WITHOUT_SM or
self._state == StanzaState.ACKED):
self._sent_future.set_result(None)
def _set_state(self, new_state, exception=None):
self._state = new_state
self._state_exception = exception
if self.on_state_change is not None:
self.on_state_change(self, new_state)
if self._sent_future is not None:
self._update_future()
def abort(self):
"""
Abort the stanza. Attempting to call this when the stanza is in any
non-:class:`~StanzaState.ACTIVE`, non-:class:`~StanzaState.ABORTED`
state results in a :class:`RuntimeError`.
When a stanza is aborted, it will reside in the active queue of the
stream, not will be sent and instead discarded silently.
"""
if (self._state != StanzaState.ACTIVE and
self._state != StanzaState.ABORTED):
raise RuntimeError("cannot abort stanza (already sent)")
self._set_state(StanzaState.ABORTED)
def __repr__(self):
return "".format(id(self))
def __await__(self):
try:
yield from asyncio.shield(self.future)
except asyncio.CancelledError:
if self._state == StanzaState.ACTIVE:
self.abort()
raise
__iter__ = __await__
class StanzaStream:
"""
A stanza stream. This is the next layer of abstraction above the XMPP XML
stream, which mostly deals with stanzas (but also with certain other
stream-level elements, such as :xep:`0198` Stream Management Request/Acks).
It is independent from a specific :class:`~aioxmpp.protocol.XMLStream`
instance. A :class:`StanzaStream` can be started with one XML stream,
stopped later and then resumed with another XML stream. The user of the
:class:`StanzaStream` has to make sure that the XML streams are compatible,
identity-wise (use the same JID).
`local_jid` may be the **bare** sender JID associated with the stanza
stream. This is required for compatibility with ejabberd. If it is omitted,
communication with ejabberd instances may not work.
`loop` may be used to explicitly specify the :class:`asyncio.BaseEventLoop`
to use, otherwise the current event loop is used.
`base_logger` can be used to explicitly specify a :class:`logging.Logger`
instance to fork off the logger from. The :class:`StanzaStream` will use a
child logger of `base_logger` called ``StanzaStream``.
.. versionchanged:: 0.4
The `local_jid` argument was added.
.. versionchanged:: 0.10
Ping handling was reworked.
Starting/Stopping the stream:
.. automethod:: start
.. automethod:: stop
.. automethod:: wait_stop
.. automethod:: close
.. autoattribute:: running
.. automethod:: flush_incoming
Timeout configuration (see
:ref:`aioxmpp.stream.General Information.Timeouts`):
.. autoattribute:: round_trip_time
.. autoattribute:: soft_timeout
Sending stanzas:
.. deprecated:: 0.10
Sending stanzas directly on the stream is deprecated. The methods
have been moved to the client:
.. autosummary::
aioxmpp.Client.send
aioxmpp.Client.enqueue
.. automethod:: send
.. automethod:: enqueue
.. method:: enqueue_stanza
Alias of :meth:`enqueue`.
.. deprecated:: 0.8
This alias is deprecated and will be removed in 1.0.
.. automethod:: send_and_wait_for_sent
.. automethod:: send_iq_and_wait_for_reply
Receiving stanzas:
.. automethod:: register_iq_request_handler
.. automethod:: unregister_iq_request_handler
.. automethod:: register_message_callback
.. automethod:: unregister_message_callback
.. automethod:: register_presence_callback
.. automethod:: unregister_presence_callback
Rarely used registries / deprecated aliases:
.. automethod:: register_iq_request_coro
.. automethod:: unregister_iq_request_coro
.. automethod:: register_iq_response_future
.. automethod:: register_iq_response_callback
.. automethod:: unregister_iq_response
Inbound Stanza Filters (see
:ref:`aioxmpp.stream.General Information.Filters`):
.. attribute:: app_inbound_presence_filter
This is a :class:`AppFilter` based filter chain on inbound presence
stanzas. It can be used to attach application-specific filters.
.. attribute:: service_inbound_presence_filter
This is another filter chain for inbound presence stanzas. It runs
*before* the :attr:`app_inbound_presence_filter` chain and all functions
registered there must have :class:`service.Service` *classes* as `order`
value (see :meth:`Filter.register`).
This filter chain is intended to be used by library services, such as a
:xep:`115` implementation which may start a :xep:`30` lookup at the
target entity to resolve the capability hash or prime the :xep:`30`
cache with the service information obtained by interpreting the
:xep:`115` hash value.
.. attribute:: app_inbound_message_filter
This is a :class:`AppFilter` based filter chain on inbound message
stanzas. It can be used to attach application-specific filters.
.. attribute:: service_inbound_message_filter
This is the analogon of :attr:`service_inbound_presence_filter` for
:attr:`app_inbound_message_filter`.
Outbound Stanza Filters (see
:ref:`aioxmpp.stream.General Information.Filters`):
.. attribute:: app_outbound_presence_filter
This is a :class:`AppFilter` based filter chain on outbound presence
stanzas. It can be used to attach application-specific filters.
Before using this attribute, make sure that you have read the notes
above.
.. attribute:: service_outbound_presence_filter
This is the analogon of :attr:`service_inbound_presence_filter`, but for
outbound presence. It runs *after* the
:meth:`app_outbound_presence_filter`.
Before using this attribute, make sure that you have read the notes
above.
.. attribute:: app_outbound_message_filter
This is a :class:`AppFilter` based filter chain on inbound message
stanzas. It can be used to attach application-specific filters.
Before using this attribute, make sure that you have read the notes
above.
.. attribute:: service_outbound_messages_filter
This is the analogon of :attr:`service_outbound_presence_filter`, but
for outbound messages.
Before using this attribute, make sure that you have read the notes
above.
Using stream management:
.. automethod:: start_sm
.. automethod:: resume_sm
.. automethod:: stop_sm
.. autoattribute:: sm_enabled
Stream management state inspection:
.. autoattribute:: sm_outbound_base
.. autoattribute:: sm_inbound_ctr
.. autoattribute:: sm_unacked_list
.. autoattribute:: sm_id
.. autoattribute:: sm_max
.. autoattribute:: sm_location
.. autoattribute:: sm_resumable
Miscellaneous:
.. autoattribute:: local_jid
Signals:
.. signal:: on_failure(exc)
Emits when the stream has failed, i.e. entered stopped state without
request by the user.
:param exc: The exception which caused the stream to fail.
:type exc: :class:`Exception`
A failure occurs whenever the main task of the :class:`StanzaStream`
(the one started by :meth:`start`) terminates with an exception.
Examples are :class:`ConnectionError` as raised upon a ping timeout and
any exceptions which may be raised by the
:meth:`aioxmpp.protocol.XMLStream.send_xso` method.
Before :meth:`on_failure` is emitted, the :class:`~.protocol.XMLStream`
is :meth:`~.protocol.XMLStream.abort`\\ -ed if SM is enabled and
:meth:`~.protocol.XMLStream.close`\\ -ed if SM is not enabled.
.. versionchanged:: 0.6
The closing behaviour was added.
.. signal:: on_stream_destroyed(reason)
The stream has been stopped in a manner which means that all state must
be discarded.
:param reason: The exception which caused the stream to be destroyed.
:type reason: :class:`Exception`
When this signal is emitted, others have or will most likely see
unavailable presence from the XMPP resource associated with the stream,
and stanzas sent in the mean time are not guaranteed to be received.
`reason` may be a :class:`DestructionRequested` instance to indicate
that the destruction was requested by the user, in some way.
There is no guarantee (it is not even likely) that it is possible to
send stanzas over the stream at the time this signal is emitted.
.. versionchanged:: 0.8
The `reason` argument was added.
.. signal:: on_stream_established()
When a stream is newly established, this signal is fired. This happens
whenever a non-SM stream is started and whenever a stream which
previously had SM disabled is started with SM enabled.
.. signal:: on_message_received(stanza)
Emits when a :class:`aioxmpp.Message` stanza has been received.
:param stanza: The received stanza.
:type stanza: :class:`aioxmpp.Message`
.. seealso::
:class:`aioxmpp.dispatcher.SimpleMessageDispatcher`
for a service which allows to register callbacks for messages
based on the sender and type of the message.
.. versionadded:: 0.9
.. signal:: on_presence_received(stanza)
Emits when a :class:`aioxmpp.Presence` stanza has been received.
:param stanza: The received stanza.
:type stanza: :class:`aioxmpp.Presence`
.. seealso::
:class:`aioxmpp.dispatcher.SimplePresenceDispatcher`
for a service which allows to register callbacks for presences
based on the sender and type of the message.
.. versionadded:: 0.9
"""
_ALLOW_ENUM_COERCION = True
on_failure = callbacks.Signal()
on_stream_destroyed = callbacks.Signal()
on_stream_established = callbacks.Signal()
on_message_received = callbacks.Signal()
on_presence_received = callbacks.Signal()
def __init__(self,
local_jid=None,
*,
loop=None,
base_logger=logging.getLogger("aioxmpp")):
super().__init__()
self._loop = loop or asyncio.get_event_loop()
self._logger = base_logger.getChild("StanzaStream")
self._task = None
self._xmlstream = None
self._soft_timeout = timedelta(minutes=1)
self._round_trip_time = timedelta(minutes=1)
self._xxx_message_dispatcher = None
self._xxx_presence_dispatcher = None
self._local_jid = local_jid
self._active_queue = custom_queue.AsyncDeque(loop=self._loop)
self._incoming_queue = custom_queue.AsyncDeque(loop=self._loop)
self._iq_response_map = callbacks.TagDispatcher()
self._iq_request_map = {}
# list of running IQ request coroutines: used to cancel them when the
# stream is destroyed
self._iq_request_tasks = []
self._xmlstream_exception = None
self._established = False
self._closed = False
self._sm_enabled = False
self._broker_lock = asyncio.Lock()
self.app_inbound_presence_filter = AppFilter()
self.service_inbound_presence_filter = callbacks.Filter()
self.app_inbound_message_filter = AppFilter()
self.service_inbound_message_filter = callbacks.Filter()
self.app_outbound_presence_filter = AppFilter()
self.service_outbound_presence_filter = callbacks.Filter()
self.app_outbound_message_filter = AppFilter()
self.service_outbound_message_filter = callbacks.Filter()
@property
def local_jid(self):
"""
The `local_jid` argument to the constructor.
.. warning::
Changing this arbitrarily while the stream is running may have
unintended side effects.
"""
return self._local_jid
@local_jid.setter
def local_jid(self, value):
self._local_jid = value
@property
def round_trip_time(self):
"""
The maximum expected round-trip time as :class:`datetime.timedelta`.
This is used to configure the maximum time between asking the server to
send something and receiving something from the server in stream
aliveness checks.
This does **not** affect IQ requests or other stanzas.
If set to :data:`None`, no application-level timeouts are used at all.
This is not recommended since TCP timeouts are generally not sufficient
for interactive applications.
"""
return self._round_trip_time
@round_trip_time.setter
def round_trip_time(self, value):
self._round_trip_time = value
self._update_xmlstream_limits()
@property
def soft_timeout(self):
"""
Soft timeout after which the server will be asked to send something
if nothing has been received.
If set to :data:`None`, no application-level timeouts are used at all.
This is not recommended since TCP timeouts are generally not sufficient
for interactive applications.
"""
return self._soft_timeout
@soft_timeout.setter
def soft_timeout(self, value):
self._soft_timeout = value
self._update_xmlstream_limits()
def _coerce_enum(self, value, enum_class):
if not isinstance(value, enum_class):
if self._ALLOW_ENUM_COERCION:
warnings.warn(
"passing a non-enum value as type_ is deprecated and will "
"be invalid as of aioxmpp 1.0",
DeprecationWarning,
stacklevel=3)
return enum_class(value)
else:
raise TypeError("type_ must be {}, got {!r}".format(
enum_class.__name__,
value
))
return value
def _done_handler(self, task):
"""
Called when the main task (:meth:`_run`, :attr:`_task`) returns.
"""
try:
task.result()
except asyncio.CancelledError:
# normal termination
pass
except Exception as err:
try:
if self._sm_enabled:
self._xmlstream.abort()
else:
self._xmlstream.close()
except Exception:
pass
self.on_failure(err)
self._logger.debug("broker task failed", exc_info=True)
def _xmlstream_failed(self, exc):
self._xmlstream_exception = exc
self.stop()
def _destroy_stream_state(self, exc):
"""
Destroy all state which does not make sense to keep after a disconnect
(without stream management).
"""
self._logger.debug("destroying stream state (exc=%r)", exc)
self._iq_response_map.close_all(exc)
for task in self._iq_request_tasks:
# we don’t need to remove, that’s handled by their
# add_done_callback
task.cancel()
while not self._active_queue.empty():
token = self._active_queue.get_nowait()
token._set_state(StanzaState.DISCONNECTED)
if self._established:
self.on_stream_destroyed(exc)
self._established = False
def _compose_undefined_condition(self, request):
response = request.make_reply(type_=structs.IQType.ERROR)
response.error = stanza.Error(
condition=errors.ErrorCondition.UNDEFINED_CONDITION,
type_=structs.ErrorType.CANCEL,
)
return response
def _send_iq_reply(self, request, result):
try:
if isinstance(result, errors.XMPPError):
response = request.make_reply(type_=structs.IQType.ERROR)
response.error = stanza.Error.from_exception(result)
else:
response = request.make_reply(type_=structs.IQType.RESULT)
response.payload = result
except Exception:
self._logger.exception("invalid payload for an IQ response")
response = self._compose_undefined_condition(
request
)
self._enqueue(response)
def _iq_request_coro_done_remove_task(self, task):
self._iq_request_tasks.remove(task)
def _iq_request_coro_done_send_reply(self, request, task):
"""
Called when an IQ request handler coroutine returns. `request` holds
the IQ request which triggered the execution of the coroutine and
`task` is the :class:`asyncio.Task` which tracks the running coroutine.
Compose a response and send that response.
"""
try:
payload = task.result()
except errors.XMPPError as err:
self._send_iq_reply(request, err)
except Exception:
response = self._compose_undefined_condition(request)
self._enqueue(response)
self._logger.exception("IQ request coroutine failed")
else:
self._send_iq_reply(request, payload)
def _iq_request_coro_done_check(self, task):
try:
task.result()
except Exception:
self._logger.exception("IQ request coroutine failed")
def _process_incoming_iq(self, stanza_obj):
"""
Process an incoming IQ stanza `stanza_obj`. Calls the response handler,
spawns a request handler coroutine or drops the stanza while logging a
warning if no handler can be found.
"""
self._logger.debug("incoming iq: %r", stanza_obj)
if stanza_obj.type_.is_response:
# iq response
self._logger.debug("iq is response")
keys = [(stanza_obj.from_, stanza_obj.id_)]
if self._local_jid is not None:
# needed for some servers
if keys[0][0] == self._local_jid:
keys.append((None, keys[0][1]))
elif keys[0][0] is None:
keys.append((self._local_jid, keys[0][1]))
for key in keys:
try:
self._iq_response_map.unicast(key, stanza_obj)
self._logger.debug("iq response delivered to key %r", key)
break
except KeyError:
pass
else:
self._logger.warning(
"unexpected IQ response: from=%r, id=%r",
*key)
else:
# iq request
self._logger.debug("iq is request")
key = (stanza_obj.type_, type(stanza_obj.payload))
try:
coro, with_send_reply = self._iq_request_map[key]
except KeyError:
self._logger.warning(
"unhandleable IQ request: from=%r, type_=%r, payload=%r",
stanza_obj.from_,
stanza_obj.type_,
stanza_obj.payload
)
response = stanza_obj.make_reply(type_=structs.IQType.ERROR)
response.error = stanza.Error(
condition=errors.ErrorCondition.SERVICE_UNAVAILABLE,
)
self._enqueue(response)
return
args = [stanza_obj]
if with_send_reply:
def send_reply(result=None):
nonlocal task, stanza_obj, send_reply_callback
if task.done():
raise RuntimeError(
"send_reply called after the handler is done")
if task.remove_done_callback(send_reply_callback) == 0:
raise RuntimeError(
"send_reply called more than once")
task.add_done_callback(self._iq_request_coro_done_check)
self._send_iq_reply(stanza_obj, result)
args.append(send_reply)
try:
awaitable = coro(*args)
except Exception as exc:
awaitable = asyncio.Future()
awaitable.set_exception(exc)
task = asyncio.ensure_future(awaitable)
send_reply_callback = functools.partial(
self._iq_request_coro_done_send_reply,
stanza_obj)
task.add_done_callback(self._iq_request_coro_done_remove_task)
task.add_done_callback(send_reply_callback)
self._iq_request_tasks.append(task)
self._logger.debug("started task to handle request: %r", task)
def _process_incoming_message(self, stanza_obj):
"""
Process an incoming message stanza `stanza_obj`.
"""
self._logger.debug("incoming message: %r", stanza_obj)
stanza_obj = self.service_inbound_message_filter.filter(stanza_obj)
if stanza_obj is None:
self._logger.debug("incoming message dropped by service "
"filter chain")
return
stanza_obj = self.app_inbound_message_filter.filter(stanza_obj)
if stanza_obj is None:
self._logger.debug("incoming message dropped by application "
"filter chain")
return
self.on_message_received(stanza_obj)
def _process_incoming_presence(self, stanza_obj):
"""
Process an incoming presence stanza `stanza_obj`.
"""
self._logger.debug("incoming presence: %r", stanza_obj)
stanza_obj = self.service_inbound_presence_filter.filter(stanza_obj)
if stanza_obj is None:
self._logger.debug("incoming presence dropped by service filter"
" chain")
return
stanza_obj = self.app_inbound_presence_filter.filter(stanza_obj)
if stanza_obj is None:
self._logger.debug("incoming presence dropped by application "
"filter chain")
return
self.on_presence_received(stanza_obj)
def _process_incoming_erroneous_stanza(self, stanza_obj, exc):
self._logger.debug(
"erroneous stanza received (may be incomplete): %r",
stanza_obj,
exc_info=exc,
)
try:
type_ = stanza_obj.type_
except AttributeError:
# ugh, type is broken
# exit early
self._logger.debug(
"stanza has broken type, cannot properly handle"
)
return
if type_.is_response:
try:
from_ = stanza_obj.from_
id_ = stanza_obj.id_
except AttributeError:
pass
else:
if isinstance(stanza_obj, stanza.IQ):
self._logger.debug(
"erroneous stanza can be forwarded to handlers as "
"error"
)
key = (from_, id_)
try:
self._iq_response_map.unicast_error(
key,
errors.ErroneousStanza(stanza_obj)
)
except KeyError:
pass
elif isinstance(exc, stanza.UnknownIQPayload):
reply = stanza_obj.make_error(error=stanza.Error(
condition=errors.ErrorCondition.SERVICE_UNAVAILABLE
))
self._enqueue(reply)
elif isinstance(exc, stanza.PayloadParsingError):
reply = stanza_obj.make_error(error=stanza.Error(
condition=errors.ErrorCondition.BAD_REQUEST
))
self._enqueue(reply)
def _process_incoming(self, xmlstream, queue_entry):
"""
Dispatch to the different methods responsible for the different stanza
types or handle a non-stanza stream-level element from `stanza_obj`,
which has arrived over the given `xmlstream`.
"""
stanza_obj, exc = queue_entry
# first, handle SM stream objects
if isinstance(stanza_obj, nonza.SMAcknowledgement):
self._logger.debug("received SM ack: %r", stanza_obj)
if not self._sm_enabled:
self._logger.warning("received SM ack, but SM not enabled")
return
self.sm_ack(stanza_obj.counter)
return
elif isinstance(stanza_obj, nonza.SMRequest):
self._logger.debug("received SM request: %r", stanza_obj)
if not self._sm_enabled:
self._logger.warning("received SM request, but SM not enabled")
return
response = nonza.SMAcknowledgement()
response.counter = self._sm_inbound_ctr
self._logger.debug("sending SM ack: %r", response)
xmlstream.send_xso(response)
return
# raise if it is not a stanza
if not isinstance(stanza_obj, stanza.StanzaBase):
raise RuntimeError(
"unexpected stanza class: {}".format(stanza_obj))
# now handle stanzas, these always increment the SM counter
if self._sm_enabled:
self._sm_inbound_ctr += 1
self._sm_inbound_ctr &= 0xffffffff
# check if the stanza has errors
if exc is not None:
self._process_incoming_erroneous_stanza(stanza_obj, exc)
return
if isinstance(stanza_obj, stanza.IQ):
self._process_incoming_iq(stanza_obj)
elif isinstance(stanza_obj, stanza.Message):
self._process_incoming_message(stanza_obj)
elif isinstance(stanza_obj, stanza.Presence):
self._process_incoming_presence(stanza_obj)
def flush_incoming(self):
"""
Flush all incoming queues to the respective processing methods. The
handlers are called as usual, thus it may require at least one
iteration through the asyncio event loop before effects can be seen.
The incoming queues are empty after a call to this method.
It is legal (but pretty useless) to call this method while the stream
is :attr:`running`.
"""
self._logger.debug("flushing incoming queue")
while True:
try:
stanza_obj = self._incoming_queue.get_nowait()
except asyncio.QueueEmpty:
break
self._process_incoming(None, stanza_obj)
def _send_stanza(self, xmlstream, token):
"""
Send a stanza token `token` over the given `xmlstream`.
Only sends if the `token` has not been aborted (see
:meth:`StanzaToken.abort`). Sends the state of the token according to
:attr:`sm_enabled`.
"""
if token.state == StanzaState.ABORTED:
return
stanza_obj = token.stanza
if isinstance(stanza_obj, stanza.Presence):
stanza_obj = self.app_outbound_presence_filter.filter(
stanza_obj
)
if stanza_obj is not None:
stanza_obj = self.service_outbound_presence_filter.filter(
stanza_obj
)
elif isinstance(stanza_obj, stanza.Message):
stanza_obj = self.app_outbound_message_filter.filter(
stanza_obj
)
if stanza_obj is not None:
stanza_obj = self.service_outbound_message_filter.filter(
stanza_obj
)
if stanza_obj is None:
token._set_state(StanzaState.DROPPED)
self._logger.debug("outgoing stanza %r dropped by filter chain",
token.stanza)
return
self._logger.debug("forwarding stanza to xmlstream: %r",
stanza_obj)
try:
xmlstream.send_xso(stanza_obj)
except Exception as exc:
self._logger.warning("failed to send stanza", exc_info=True)
token._set_state(StanzaState.FAILED, exc)
return
if self._sm_enabled:
token._set_state(StanzaState.SENT)
self._sm_unacked_list.append(token)
else:
token._set_state(StanzaState.SENT_WITHOUT_SM)
def _process_outgoing(self, xmlstream, token):
"""
Process the current outgoing stanza `token` and also any other outgoing
stanza which is currently in the active queue. After all stanzas have
been processed, use :meth:`_send_ping` to allow an opportunistic ping
to be sent.
"""
self._send_stanza(xmlstream, token)
# try to send a bulk
while True:
try:
token = self._active_queue.get_nowait()
except asyncio.QueueEmpty:
break
self._send_stanza(xmlstream, token)
if self._sm_enabled:
self._logger.debug("sending SM req")
xmlstream.send_xso(nonza.SMRequest())
def register_iq_response_callback(self, from_, id_, cb):
"""
Register a callback function `cb` to be called when a IQ stanza with
type ``result`` or ``error`` is received from the
:class:`~aioxmpp.JID` `from_` with the id `id_`.
The callback is called at most once.
.. note::
In contrast to :meth:`register_iq_response_future`, errors which
occur on a level below XMPP stanzas cannot be caught using a
callback.
If you need notification about other errors and still want to use
callbacks, use of a future with
:meth:`asyncio.Future.add_done_callback` is recommended.
"""
self._iq_response_map.add_listener(
(from_, id_),
callbacks.OneshotAsyncTagListener(cb, loop=self._loop)
)
self._logger.debug("iq response callback registered: from=%r, id=%r",
from_, id_)
def register_iq_response_future(self, from_, id_, fut):
"""
Register a future `fut` for an IQ stanza with type ``result`` or
``error`` from the :class:`~aioxmpp.JID` `from_` with the id
`id_`.
If the type of the IQ stanza is ``result``, the stanza is set as result
to the future. If the type of the IQ stanza is ``error``, the stanzas
error field is converted to an exception and set as the exception of
the future.
The future might also receive different exceptions:
* :class:`.errors.ErroneousStanza`, if the response stanza received
could not be parsed.
Note that this exception is not emitted if the ``from`` address of
the stanza is unset, because the code cannot determine whether a
sender deliberately used an erroneous address to make parsing fail
or no sender address was used. In the former case, an attacker could
use that to inject a stanza which would be taken as a stanza from the
peer server. Thus, the future will never be fulfilled in these
cases.
Also note that this exception does not derive from
:class:`.errors.XMPPError`, as it cannot provide the same
attributes. Instead, it derives from :class:`.errors.StanzaError`,
from which :class:`.errors.XMPPError` also derives; to catch all
possible stanza errors, catching :class:`.errors.StanzaError` is
sufficient and future-proof.
* :class:`ConnectionError` if the stream is :meth:`stop`\\ -ped (only
if SM is not enabled) or :meth:`close`\\ -ed.
* Any :class:`Exception` which may be raised from
:meth:`~.protocol.XMLStream.send_xso`, which are generally also
:class:`ConnectionError` or at least :class:`OSError` subclasses.
"""
self._iq_response_map.add_listener(
(from_, id_),
StanzaErrorAwareListener(
callbacks.FutureListener(fut)
)
)
self._logger.debug("iq response future registered: from=%r, id=%r",
from_, id_)
def unregister_iq_response(self, from_, id_):
"""
Unregister a registered callback or future for the IQ response
identified by `from_` and `id_`. See
:meth:`register_iq_response_future` or
:meth:`register_iq_response_callback` for details on the arguments
meanings and how to register futures and callbacks respectively.
.. note::
Futures will automatically be unregistered when they are cancelled.
"""
self._iq_response_map.remove_listener((from_, id_))
self._logger.debug("iq response unregistered: from=%r, id=%r",
from_, id_)
def register_iq_request_coro(self, type_, payload_cls, coro):
"""
Alias of :meth:`register_iq_request_handler`.
.. deprecated:: 0.10
This alias will be removed in version 1.0.
"""
warnings.warn(
"register_iq_request_coro is a deprecated alias to "
"register_iq_request_handler and will be removed in aioxmpp 1.0",
DeprecationWarning,
stacklevel=2)
return self.register_iq_request_handler(type_, payload_cls, coro)
def register_iq_request_handler(self, type_, payload_cls, cb, *,
with_send_reply=False):
"""
Register a coroutine function or a function returning an awaitable to
run when an IQ request is received.
:param type_: IQ type to react to (must be a request type).
:type type_: :class:`~aioxmpp.IQType`
:param payload_cls: Payload class to react to (subclass of
:class:`~xso.XSO`)
:type payload_cls: :class:`~.XMLStreamClass`
:param cb: Function or coroutine function to invoke
:param with_send_reply: Whether to pass a function to send a reply
to `cb` as second argument.
:type with_send_reply: :class:`bool`
:raises ValueError: if there is already a coroutine registered for this
target
:raises ValueError: if `type_` is not a request IQ type
:raises ValueError: if `type_` is not a valid
:class:`~.IQType` (and cannot be cast to a
:class:`~.IQType`)
The callback `cb` will be called whenever an IQ stanza with the given
`type_` and payload being an instance of the `payload_cls` is received.
The callback must either be a coroutine function or otherwise return an
awaitable. The awaitable must evaluate to a valid value for the
:attr:`.IQ.payload` attribute. That value will be set as the payload
attribute value of an IQ response (with type :attr:`~.IQType.RESULT`)
which is generated and sent by the stream.
If the awaitable or the function raises an exception, it will be
converted to a :class:`~.stanza.Error` object. That error object is
then used as payload for an IQ response (with type
:attr:`~.IQType.ERROR`) which is generated and sent by the stream.
If the exception is a subclass of :class:`aioxmpp.errors.XMPPError`, it
is converted to an :class:`~.stanza.Error` instance directly.
Otherwise, it is wrapped in a :class:`aioxmpp.XMPPCancelError`
with ``undefined-condition``.
For this to work, `payload_cls` *must* be registered using
:meth:`~.IQ.as_payload_class`. Otherwise, the payload will
not be recognised by the stream parser and the IQ is automatically
responded to with a ``feature-not-implemented`` error.
.. warning::
When using a coroutine function for `cb`, there is no guarantee
that concurrent IQ handlers and other coroutines will execute in
any defined order. This implies that the strong ordering guarantees
normally provided by XMPP XML Streams are lost when using coroutine
functions for `cb`. For this reason, the use of non-coroutine
functions is allowed.
.. note::
Using a non-coroutine function for `cb` will generally lead to
less readable code. For the sake of readability, it is recommended
to prefer coroutine functions when strong ordering guarantees are
not needed.
.. versionadded:: 0.11
When the argument `with_send_reply` is true `cb` will be
called with two arguments: the IQ stanza to handle and a
unary function `send_reply(result=None)` that sends a
response to the IQ request and prevents that an automatic
response is sent. If `result` is an instance of
:class:`~aioxmpp.XMPPError` an error result is generated.
This is useful when the handler function needs to execute
actions which happen after the IQ result has been sent,
for example, sending other stanzas.
.. versionchanged:: 0.10
Accepts an awaitable as last argument in addition to coroutine
functions.
Renamed from :meth:`register_iq_request_coro`.
.. versionadded:: 0.6
If the stream is :meth:`stop`\\ -ped (only if SM is not enabled) or
:meth:`close`\\ ed, running IQ response coroutines are
:meth:`asyncio.Task.cancel`\\ -led.
To protect against that, fork from your coroutine using
:func:`asyncio.ensure_future`.
.. versionchanged:: 0.7
The `type_` argument is now supposed to be a :class:`~.IQType`
member.
.. deprecated:: 0.7
Passing a :class:`str` as `type_` argument is deprecated and will
raise a :class:`TypeError` as of the 1.0 release. See the Changelog
for :ref:`api-changelog-0.7` for further details on how to upgrade
your code efficiently.
"""
type_ = self._coerce_enum(type_, structs.IQType)
if not type_.is_request:
raise ValueError(
"{!r} is not a request IQType".format(type_)
)
key = type_, payload_cls
if key in self._iq_request_map:
raise ValueError("only one listener is allowed per tag")
self._iq_request_map[key] = cb, with_send_reply
self._logger.debug(
"iq request coroutine registered: type=%r, payload=%r",
type_, payload_cls)
def unregister_iq_request_coro(self, type_, payload_cls):
warnings.warn(
"unregister_iq_request_coro is a deprecated alias to "
"unregister_iq_request_handler and will be removed in aioxmpp 1.0",
DeprecationWarning,
stacklevel=2,
)
return self.unregister_iq_request_handler(type_, payload_cls)
def unregister_iq_request_handler(self, type_, payload_cls):
"""
Unregister a coroutine previously registered with
:meth:`register_iq_request_handler`.
:param type_: IQ type to react to (must be a request type).
:type type_: :class:`~structs.IQType`
:param payload_cls: Payload class to react to (subclass of
:class:`~xso.XSO`)
:type payload_cls: :class:`~.XMLStreamClass`
:raises KeyError: if no coroutine has been registered for the given
``(type_, payload_cls)`` pair
:raises ValueError: if `type_` is not a valid
:class:`~.IQType` (and cannot be cast to a
:class:`~.IQType`)
The match is solely made using the `type_` and `payload_cls` arguments,
which have the same meaning as in :meth:`register_iq_request_coro`.
.. versionchanged:: 0.10
Renamed from :meth:`unregister_iq_request_coro`.
.. versionchanged:: 0.7
The `type_` argument is now supposed to be a :class:`~.IQType`
member.
.. deprecated:: 0.7
Passing a :class:`str` as `type_` argument is deprecated and will
raise a :class:`TypeError` as of the 1.0 release. See the Changelog
for :ref:`api-changelog-0.7` for further details on how to upgrade
your code efficiently.
"""
type_ = self._coerce_enum(type_, structs.IQType)
del self._iq_request_map[type_, payload_cls]
self._logger.debug(
"iq request coroutine unregistered: type=%r, payload=%r",
type_, payload_cls)
def register_message_callback(self, type_, from_, cb):
"""
Register a callback to be called when a message is received.
:param type_: Message type to listen for, or :data:`None` for a
wildcard match.
:type type_: :class:`~.MessageType` or :data:`None`
:param from_: Sender JID to listen for, or :data:`None` for a wildcard
match.
:type from_: :class:`~aioxmpp.JID` or :data:`None`
:param cb: Callback function to call
:raises ValueError: if another function is already registered for the
same ``(type_, from_)`` pair.
:raises ValueError: if `type_` is not a valid
:class:`~.MessageType` (and cannot be cast
to a :class:`~.MessageType`)
`cb` will be called whenever a message stanza matching the `type_` and
`from_` is received, according to the wildcarding rules below. More
specific callbacks win over less specific callbacks, and the match on
the `from_` address takes precedence over the match on the `type_`.
See :meth:`.SimpleStanzaDispatcher.register_callback` for the exact
wildcarding rules.
.. versionchanged:: 0.7
The `type_` argument is now supposed to be a
:class:`~.MessageType` member.
.. deprecated:: 0.7
Passing a :class:`str` as `type_` argument is deprecated and will
raise a :class:`TypeError` as of the 1.0 release. See the Changelog
for :ref:`api-changelog-0.7` for further details on how to upgrade
your code efficiently.
.. deprecated:: 0.9
This method has been deprecated in favour of and is now implemented
in terms of the :class:`aioxmpp.dispatcher.SimpleMessageDispatcher`
service.
It is equivalent to call
:meth:`~.SimpleStanzaDispatcher.register_callback`, except that the
latter is not deprecated.
"""
if type_ is not None:
type_ = self._coerce_enum(type_, structs.MessageType)
warnings.warn(
"register_message_callback is deprecated; use "
"aioxmpp.dispatcher.SimpleMessageDispatcher instead",
DeprecationWarning,
stacklevel=2
)
self._xxx_message_dispatcher.register_callback(
type_,
from_,
cb,
)
def unregister_message_callback(self, type_, from_):
"""
Unregister a callback previously registered with
:meth:`register_message_callback`.
:param type_: Message type to listen for.
:type type_: :class:`~.MessageType` or :data:`None`
:param from_: Sender JID to listen for.
:type from_: :class:`~aioxmpp.JID` or :data:`None`
:raises KeyError: if no function is currently registered for the given
``(type_, from_)`` pair.
:raises ValueError: if `type_` is not a valid
:class:`~.MessageType` (and cannot be cast
to a :class:`~.MessageType`)
The match is made on the exact pair; it is not possible to unregister
arbitrary listeners by passing :data:`None` to both arguments (i.e. the
wildcarding only applies for receiving stanzas, not for unregistering
callbacks; unregistering the super-wildcard with both arguments set to
:data:`None` is of course possible).
.. versionchanged:: 0.7
The `type_` argument is now supposed to be a
:class:`~.MessageType` member.
.. deprecated:: 0.7
Passing a :class:`str` as `type_` argument is deprecated and will
raise a :class:`TypeError` as of the 1.0 release. See the Changelog
for :ref:`api-changelog-0.7` for further details on how to upgrade
your code efficiently.
.. deprecated:: 0.9
This method has been deprecated in favour of and is now implemented
in terms of the :class:`aioxmpp.dispatcher.SimpleMessageDispatcher`
service.
It is equivalent to call
:meth:`~.SimpleStanzaDispatcher.unregister_callback`, except that
the latter is not deprecated.
"""
if type_ is not None:
type_ = self._coerce_enum(type_, structs.MessageType)
warnings.warn(
"unregister_message_callback is deprecated; use "
"aioxmpp.dispatcher.SimpleMessageDispatcher instead",
DeprecationWarning,
stacklevel=2
)
self._xxx_message_dispatcher.unregister_callback(
type_,
from_,
)
def register_presence_callback(self, type_, from_, cb):
"""
Register a callback to be called when a presence stanza is received.
:param type_: Presence type to listen for.
:type type_: :class:`~.PresenceType`
:param from_: Sender JID to listen for, or :data:`None` for a wildcard
match.
:type from_: :class:`~aioxmpp.JID` or :data:`None`.
:param cb: Callback function
:raises ValueError: if another listener with the same ``(type_,
from_)`` pair is already registered
:raises ValueError: if `type_` is not a valid
:class:`~.PresenceType` (and cannot be cast
to a :class:`~.PresenceType`)
`cb` will be called whenever a presence stanza matching the `type_` is
received from the specified sender. `from_` may be :data:`None` to
indicate a wildcard. Like with :meth:`register_message_callback`, more
specific callbacks win over less specific callbacks. The fallback order
is identical, except that the ``type_=None`` entries described there do
not apply for presence stanzas and are thus omitted.
See :meth:`.SimpleStanzaDispatcher.register_callback` for the exact
wildcarding rules.
.. versionchanged:: 0.7
The `type_` argument is now supposed to be a
:class:`~.PresenceType` member.
.. deprecated:: 0.7
Passing a :class:`str` as `type_` argument is deprecated and will
raise a :class:`TypeError` as of the 1.0 release. See the Changelog
for :ref:`api-changelog-0.7` for further details on how to upgrade
your code efficiently.
.. deprecated:: 0.9
This method has been deprecated. It is recommended to use
:class:`aioxmpp.PresenceClient` instead.
"""
type_ = self._coerce_enum(type_, structs.PresenceType)
warnings.warn(
"register_presence_callback is deprecated; use "
"aioxmpp.dispatcher.SimplePresenceDispatcher or "
"aioxmpp.PresenceClient instead",
DeprecationWarning,
stacklevel=2
)
self._xxx_presence_dispatcher.register_callback(
type_,
from_,
cb,
)
def unregister_presence_callback(self, type_, from_):
"""
Unregister a callback previously registered with
:meth:`register_presence_callback`.
:param type_: Presence type to listen for.
:type type_: :class:`~.PresenceType`
:param from_: Sender JID to listen for, or :data:`None` for a wildcard
match.
:type from_: :class:`~aioxmpp.JID` or :data:`None`.
:raises KeyError: if no callback is currently registered for the given
``(type_, from_)`` pair
:raises ValueError: if `type_` is not a valid
:class:`~.PresenceType` (and cannot be cast
to a :class:`~.PresenceType`)
The match is made on the exact pair; it is not possible to unregister
arbitrary listeners by passing :data:`None` to the `from_` arguments
(i.e. the wildcarding only applies for receiving stanzas, not for
unregistering callbacks; unregistering a wildcard match with `from_`
set to :data:`None` is of course possible).
.. versionchanged:: 0.7
The `type_` argument is now supposed to be a
:class:`~.PresenceType` member.
.. deprecated:: 0.7
Passing a :class:`str` as `type_` argument is deprecated and will
raise a :class:`TypeError` as of the 1.0 release. See the Changelog
for :ref:`api-changelog-0.7` for further details on how to upgrade
your code efficiently.
.. deprecated:: 0.9
This method has been deprecated. It is recommended to use
:class:`aioxmpp.PresenceClient` instead.
"""
type_ = self._coerce_enum(type_, structs.PresenceType)
warnings.warn(
"unregister_presence_callback is deprecated; use "
"aioxmpp.dispatcher.SimplePresenceDispatcher or "
"aioxmpp.PresenceClient instead",
DeprecationWarning,
stacklevel=2
)
self._xxx_presence_dispatcher.unregister_callback(
type_,
from_,
)
def _xmlstream_soft_limit_tripped(self, xmlstream):
self._logger.debug(
"XMLStream has reached dead-time soft limit, sending ping"
)
if self._sm_enabled:
req = nonza.SMRequest()
xmlstream.send_xso(req)
else:
iq = stanza.IQ(
type_=structs.IQType.GET,
payload=ping.Ping()
)
iq.autoset_id()
self.register_iq_response_callback(
None,
iq.id_,
# we don’t care, just wanna make sure that this doesn’t fail
lambda stanza: None,
)
self._enqueue(iq)
def _start_prepare(self, xmlstream, receiver):
self._xmlstream_failure_token = xmlstream.on_closing.connect(
self._xmlstream_failed
)
self._xmlstream_soft_limit_token = \
xmlstream.on_deadtime_soft_limit_tripped.connect(
functools.partial(self._xmlstream_soft_limit_tripped,
xmlstream)
)
xmlstream.stanza_parser.add_class(stanza.IQ, receiver)
xmlstream.stanza_parser.add_class(stanza.Message, receiver)
xmlstream.stanza_parser.add_class(stanza.Presence, receiver)
xmlstream.error_handler = self.recv_erroneous_stanza
if self._sm_enabled:
self._logger.debug("using SM")
xmlstream.stanza_parser.add_class(nonza.SMAcknowledgement,
receiver)
xmlstream.stanza_parser.add_class(nonza.SMRequest,
receiver)
self._xmlstream_exception = None
def _start_rollback(self, xmlstream):
xmlstream.error_handler = None
xmlstream.stanza_parser.remove_class(stanza.Presence)
xmlstream.stanza_parser.remove_class(stanza.Message)
xmlstream.stanza_parser.remove_class(stanza.IQ)
if self._sm_enabled:
xmlstream.stanza_parser.remove_class(
nonza.SMRequest)
xmlstream.stanza_parser.remove_class(
nonza.SMAcknowledgement)
xmlstream.on_closing.disconnect(
self._xmlstream_failure_token
)
xmlstream.on_deadtime_soft_limit_tripped.disconnect(
self._xmlstream_soft_limit_token
)
def _update_xmlstream_limits(self):
if self._xmlstream is None:
return
self._xmlstream.deadtime_soft_limit = self._soft_timeout
if (self._soft_timeout is not None and
self._round_trip_time is not None):
self._xmlstream.deadtime_hard_limit = \
self._soft_timeout + self._round_trip_time
else:
self._xmlstream.deadtime_hard_limit = None
def _start_commit(self, xmlstream):
if not self._established:
self.on_stream_established()
self._established = True
self._task = asyncio.ensure_future(self._run(xmlstream),
loop=self._loop)
self._task.add_done_callback(self._done_handler)
self._logger.debug("broker task started as %r", self._task)
def start(self, xmlstream):
"""
Start or resume the stanza stream on the given
:class:`aioxmpp.protocol.XMLStream` `xmlstream`.
This starts the main broker task, registers stanza classes at the
`xmlstream` .
"""
if self.running:
raise RuntimeError("already started")
self._start_prepare(xmlstream, self.recv_stanza)
self._closed = False
self._start_commit(xmlstream)
def stop(self):
"""
Send a signal to the main broker task to terminate. You have to check
:attr:`running` and possibly wait for it to become :data:`False` ---
the task takes at least one loop through the event loop to terminate.
It is guaranteed that the task will not attempt to send stanzas over
the existing `xmlstream` after a call to :meth:`stop` has been made.
It is legal to call :meth:`stop` even if the task is already
stopped. It is a no-op in that case.
"""
if not self.running:
return
self._logger.debug("sending stop signal to task")
self._task.cancel()
async def wait_stop(self):
"""
Stop the stream and wait for it to stop.
See :meth:`stop` for the general stopping conditions. You can assume
that :meth:`stop` is the first thing this coroutine calls.
"""
if not self.running:
return
self.stop()
try:
await self._task
except asyncio.CancelledError:
pass
async def close(self):
"""
Close the stream and the underlying XML stream (if any is connected).
This is essentially a way of saying "I do not want to use this stream
anymore" (until the next call to :meth:`start`). If the stream is
currently running, the XML stream is closed gracefully (potentially
sending an SM ack), the worker is stopped and any Stream Management
state is cleaned up.
If an error occurs while the stream stops, the error is ignored.
After the call to :meth:`close` has started, :meth:`on_failure` will
not be emitted, even if the XML stream fails before closure has
completed.
After a call to :meth:`close`, the stream is stopped, all SM state is
discarded and calls to :meth:`enqueue_stanza` raise a
:class:`DestructionRequested` ``"close() called"``. Such a
:class:`StanzaStream` can be re-started by calling :meth:`start`.
.. versionchanged:: 0.8
Before 0.8, an error during a call to :meth:`close` would stop the
stream from closing completely, and the exception was re-raised. If
SM was enabled, the state would have been kept, allowing for
resumption and ensuring that stanzas still enqueued or
unacknowledged would get a chance to be sent.
If you want to have guarantees that all stanzas sent up to a certain
point are sent, you should be using :meth:`send_and_wait_for_sent`
with stream management.
"""
exc = DestructionRequested("close() called")
if self.running:
if self.sm_enabled:
self._xmlstream.send_xso(nonza.SMAcknowledgement(
counter=self._sm_inbound_ctr
))
await self._xmlstream.close_and_wait() # does not raise
await self.wait_stop() # may raise
self._closed = True
self._xmlstream_exception = exc
self._destroy_stream_state(self._xmlstream_exception)
if self.sm_enabled:
self.stop_sm()
def _drain_incoming(self):
"""
Drain the incoming queue **without** processing any contents.
"""
self._logger.debug("draining incoming queue")
while True:
# this cannot loop for infinity because we do not yield control
# and the queue cannot be filled across threads.
try:
self._incoming_queue.get_nowait()
except asyncio.QueueEmpty:
break
async def _run(self, xmlstream):
self._xmlstream = xmlstream
self._update_xmlstream_limits()
active_fut = asyncio.ensure_future(self._active_queue.get(),
loop=self._loop)
incoming_fut = asyncio.ensure_future(self._incoming_queue.get(),
loop=self._loop)
try:
while True:
timeout = None
done, pending = await asyncio.wait(
[
active_fut,
incoming_fut,
],
return_when=asyncio.FIRST_COMPLETED,
timeout=timeout)
async with self._broker_lock:
if active_fut in done:
self._process_outgoing(xmlstream, active_fut.result())
active_fut = asyncio.ensure_future(
self._active_queue.get(),
loop=self._loop)
if incoming_fut in done:
self._process_incoming(xmlstream,
incoming_fut.result())
incoming_fut = asyncio.ensure_future(
self._incoming_queue.get(),
loop=self._loop)
finally:
# make sure we rescue any stanzas which possibly have already been
# caught by the calls to get()
self._logger.debug("task terminating, rescuing stanzas and "
"clearing handlers")
# Drain the incoming queue:
# 1. Either we have an SM-resumable stream and we'll SM-resume it,
# in which case we can reply to stanzas in here; however, then
# we'd get them re-transmitted anyway.
# 2. Or the stream is not SM-resumed, in which case it would be
# invalid to reply to anything in the incoming queue or process
# it in any way, as it may refer to old, stale state.
# Imagine an incremental roster update slipping in here and
# getting processed after a reconnect. Terrible. Let's flush
# this queue immediately.
if incoming_fut.done():
# discard
try:
incoming_fut.result()
except BaseException: # noqa
# we truly do not care, because we are going to drain the
# queue anyway. if anything is fatally wrong with the
# queue, it'll reraise there. if anything is fatally
# wrong with the event loop, it'll hit us elsewhere
# eventually. if it was successful, good, let's drop the
# stanza because we want to drain right now.
pass
else:
incoming_fut.cancel()
self._drain_incoming()
if active_fut.done() and not active_fut.exception():
self._active_queue.putleft_nowait(active_fut.result())
else:
active_fut.cancel()
# we also lock shutdown, because the main race is among the SM
# variables
async with self._broker_lock:
if not self.sm_enabled or not self.sm_resumable:
self._destroy_stream_state(
self._xmlstream_exception or
DestructionRequested(
"close() or stop() called and stream is not "
"resumable"
)
)
if self.sm_enabled:
self._stop_sm()
self._start_rollback(xmlstream)
if self._xmlstream_exception:
raise self._xmlstream_exception
def recv_stanza(self, stanza):
"""
Inject a `stanza` into the incoming queue.
"""
self._incoming_queue.put_nowait((stanza, None))
def recv_erroneous_stanza(self, partial_obj, exc):
self._incoming_queue.put_nowait((partial_obj, exc))
def _enqueue(self, stanza, **kwargs):
if self._closed:
raise self._xmlstream_exception
stanza.validate()
token = StanzaToken(stanza, **kwargs)
self._active_queue.put_nowait(token)
stanza.autoset_id()
self._logger.debug("enqueued stanza %r with token %r",
stanza, token)
return token
enqueue_stanza = _enqueue
def enqueue(self, stanza, **kwargs):
"""
Deprecated alias of :meth:`aioxmpp.Client.enqueue`.
This is only available on streams owned by a :class:`aioxmpp.Client`.
.. deprecated:: 0.10
"""
raise NotImplementedError(
"only available on streams owned by a Client"
)
@property
def running(self):
"""
:data:`True` if the broker task is currently running, and :data:`False`
otherwise.
"""
return self._task is not None and not self._task.done()
async def start_sm(self, request_resumption=True, resumption_timeout=None):
"""
Start stream management (version 3).
:param request_resumption: Request that the stream shall be resumable.
:type request_resumption: :class:`bool`
:param resumption_timeout: Maximum time in seconds for a stream to be
resumable.
:type resumption_timeout: :class:`int`
:raises aioxmpp.errors.StreamNegotiationFailure: if the server rejects
the attempt to enable stream management.
This method attempts to starts stream management on the stream.
`resumption_timeout` is the ``max`` attribute on
:class:`.nonza.SMEnabled`; it can be used to set a maximum time for
which the server shall consider the stream to still be alive after the
underlying transport (TCP) has failed. The server may impose its own
maximum or ignore the request, so there are no guarantees that the
session will stay alive for at most or at least `resumption_timeout`
seconds. Passing a `resumption_timeout` of 0 is equivalent to passing
false to `request_resumption` and takes precedence over
`request_resumption`.
.. note::
In addition to server implementation details, it is very well
possible that the server does not even detect that the underlying
transport has failed for quite some time for various reasons
(including high TCP timeouts).
If the server rejects the attempt to enable stream management, a
:class:`.errors.StreamNegotiationFailure` is raised. The stream is
still running in that case.
.. warning::
This method cannot and does not check whether the server advertised
support for stream management. Attempting to negotiate stream
management without server support might lead to termination of the
stream.
If an XML stream error occurs during the negotiation, the result
depends on a few factors. In any case, the stream is not running
afterwards. If the :class:`.nonza.SMEnabled` response was not received
before the XML stream died, SM is also disabled and the exception which
caused the stream to die is re-raised (this is due to the
implementation of :func:`~.protocol.send_and_wait_for`). If the
:class:`.nonza.SMEnabled` response was received and annonuced support
for resumption, SM is enabled. Otherwise, it is disabled. No exception
is raised if :class:`.nonza.SMEnabled` was received, as this method has
no way to determine that the stream failed.
If negotiation succeeds, this coroutine initializes a new stream
management session. The stream management state attributes become
available and :attr:`sm_enabled` becomes :data:`True`.
"""
if not self.running:
raise RuntimeError("cannot start Stream Management while"
" StanzaStream is not running")
if self.sm_enabled:
raise RuntimeError("Stream Management already enabled")
if resumption_timeout == 0:
request_resumption = False
resumption_timeout = None
# sorry for the callback spaghetti code
# we have to handle the response synchronously, so we have to use a
# callback.
# otherwise, it is possible that an SM related nonza (e.g. ) is
# received (and attempted to be deserialized) before the handlers are
# registered
# see tests/test_highlevel.py:TestProtocoltest_sm_bootstrap_race
def handle_response(response):
if isinstance(response, nonza.SMFailed):
# we handle the error down below
return
self._sm_outbound_base = 0
self._sm_inbound_ctr = 0
self._sm_unacked_list = []
self._sm_enabled = True
self._sm_id = response.id_
self._sm_resumable = response.resume
self._sm_max = response.max_
self._sm_location = response.location
self._logger.info("SM started: resumable=%s, stream id=%r",
self._sm_resumable,
self._sm_id)
# if not self._xmlstream:
# # stream died in the meantime...
# if self._xmlstream_exception:
# raise self._xmlstream_exception
self._xmlstream.stanza_parser.add_class(
nonza.SMRequest,
self.recv_stanza)
self._xmlstream.stanza_parser.add_class(
nonza.SMAcknowledgement,
self.recv_stanza)
async with self._broker_lock:
response = await protocol.send_and_wait_for(
self._xmlstream,
[
nonza.SMEnable(resume=bool(request_resumption),
max_=resumption_timeout),
],
[
nonza.SMEnabled,
nonza.SMFailed
],
cb=handle_response,
)
if isinstance(response, nonza.SMFailed):
raise errors.StreamNegotiationFailure(
"Server rejected SM request")
@property
def sm_enabled(self):
"""
:data:`True` if stream management is currently enabled on the stream,
:data:`False` otherwise.
"""
return self._sm_enabled
@property
def sm_outbound_base(self):
"""
The last value of the remote stanza counter.
.. note::
Accessing this attribute when :attr:`sm_enabled` is :data:`False`
raises :class:`RuntimeError`.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management not enabled")
return self._sm_outbound_base
@property
def sm_inbound_ctr(self):
"""
The current value of the inbound stanza counter.
.. note::
Accessing this attribute when :attr:`sm_enabled` is :data:`False`
raises :class:`RuntimeError`.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management not enabled")
return self._sm_inbound_ctr
@property
def sm_unacked_list(self):
"""
A **copy** of the list of stanza tokens which have not yet been acked
by the remote party.
.. note::
Accessing this attribute when :attr:`sm_enabled` is :data:`False`
raises :class:`RuntimeError`.
Accessing this attribute is expensive, as the list is copied. In
general, access to this attribute should not be necessary at all.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management not enabled")
return self._sm_unacked_list[:]
@property
def sm_max(self):
"""
The value of the ``max`` attribute of the
:class:`~.nonza.SMEnabled` response from the server.
.. note::
Accessing this attribute when :attr:`sm_enabled` is :data:`False`
raises :class:`RuntimeError`.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management not enabled")
return self._sm_max
@property
def sm_location(self):
"""
The value of the ``location`` attribute of the
:class:`~.nonza.SMEnabled` response from the server.
.. note::
Accessing this attribute when :attr:`sm_enabled` is :data:`False`
raises :class:`RuntimeError`.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management not enabled")
return self._sm_location
@property
def sm_id(self):
"""
The value of the ``id`` attribute of the
:class:`~.nonza.SMEnabled` response from the server.
.. note::
Accessing this attribute when :attr:`sm_enabled` is :data:`False`
raises :class:`RuntimeError`.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management not enabled")
return self._sm_id
@property
def sm_resumable(self):
"""
The value of the ``resume`` attribute of the
:class:`~.nonza.SMEnabled` response from the server.
.. note::
Accessing this attribute when :attr:`sm_enabled` is :data:`False`
raises :class:`RuntimeError`.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management not enabled")
return self._sm_resumable
def _resume_sm(self, remote_ctr):
"""
Version of :meth:`resume_sm` which can be used during slow start.
"""
self._logger.info("resuming SM stream with remote_ctr=%d", remote_ctr)
# remove any acked stanzas
self.sm_ack(remote_ctr)
# reinsert the remaining stanzas
for token in self._sm_unacked_list:
self._active_queue.putleft_nowait(token)
self._sm_unacked_list.clear()
def _clear_unacked(self, new_state, *args):
for token in self._sm_unacked_list:
token._set_state(new_state, *args)
self._sm_unacked_list.clear()
async def resume_sm(self, xmlstream):
"""
Resume an SM-enabled stream using the given `xmlstream`.
If the server rejects the attempt to resume stream management, a
:class:`.errors.StreamNegotiationFailure` is raised. The stream is then
in stopped state and stream management has been stopped.
.. warning::
This method cannot and does not check whether the server advertised
support for stream management. Attempting to negotiate stream
management without server support might lead to termination of the
stream.
If the XML stream dies at any point during the negotiation, the SM
state is left unchanged. If no response has been received yet, the
exception which caused the stream to die is re-raised. The state of the
stream depends on whether the main task already noticed the dead
stream.
If negotiation succeeds, this coroutine resumes the stream management
session and initiates the retransmission of any unacked stanzas. The
stream is then in running state.
.. versionchanged:: 0.11
Support for using the counter value provided some servers on a
failed resumption was added. Stanzas which are covered by the
counter will be marked as :attr:`~StanzaState.ACKED`; other stanzas
will be marked as :attr:`~StanzaState.DISCONNECTED`.
This is in contrast to the behaviour when resumption fails
*without* a counter given. In that case, stanzas which have not
been acked are marked as :attr:`~StanzaState.SENT_WITHOUT_SM`.
"""
if self.running:
raise RuntimeError("Cannot resume Stream Management while"
" StanzaStream is running")
self._start_prepare(xmlstream, self.recv_stanza)
try:
response = await protocol.send_and_wait_for(
xmlstream,
[
nonza.SMResume(previd=self.sm_id,
counter=self._sm_inbound_ctr)
],
[
nonza.SMResumed,
nonza.SMFailed
]
)
if isinstance(response, nonza.SMFailed):
exc = errors.StreamNegotiationFailure(
"Server rejected SM resumption"
)
if response.counter is not None:
self.sm_ack(response.counter)
self._clear_unacked(StanzaState.DISCONNECTED)
xmlstream.stanza_parser.remove_class(
nonza.SMRequest)
xmlstream.stanza_parser.remove_class(
nonza.SMAcknowledgement)
self.stop_sm()
raise exc
self._resume_sm(response.counter)
except: # NOQA
self._start_rollback(xmlstream)
raise
self._start_commit(xmlstream)
def _stop_sm(self):
"""
Version of :meth:`stop_sm` which can be called during startup.
"""
if not self.sm_enabled:
raise RuntimeError("Stream Management is not enabled")
self._logger.info("stopping SM stream")
self._sm_enabled = False
del self._sm_outbound_base
del self._sm_inbound_ctr
self._clear_unacked(StanzaState.SENT_WITHOUT_SM)
del self._sm_unacked_list
self._destroy_stream_state(ConnectionError(
"stream management disabled"
))
def stop_sm(self):
"""
Disable stream management on the stream.
Attempting to call this method while the stream is running or without
stream management enabled results in a :class:`RuntimeError`.
Any sent stanzas which have not been acked by the remote yet are put
into :attr:`StanzaState.SENT_WITHOUT_SM` state.
"""
if self.running:
raise RuntimeError("Cannot stop Stream Management while"
" StanzaStream is running")
return self._stop_sm()
def sm_ack(self, remote_ctr):
"""
Process the remote stanza counter `remote_ctr`. Any acked stanzas are
dropped from :attr:`sm_unacked_list` and put into
:attr:`StanzaState.ACKED` state and the counters are increased
accordingly.
If called with an erroneous remote stanza counter
:class:`.errors.StreamNegotationFailure` will be raised.
Attempting to call this without Stream Management enabled results in a
:class:`RuntimeError`.
"""
if not self._sm_enabled:
raise RuntimeError("Stream Management is not enabled")
self._logger.debug("sm_ack(%d)", remote_ctr)
to_drop = (remote_ctr - self._sm_outbound_base) & 0xffffffff
self._logger.debug("sm_ack: to drop %d, unacked: %d",
to_drop, len(self._sm_unacked_list))
if to_drop > len(self._sm_unacked_list):
raise errors.StreamNegotiationFailure(
"acked more stanzas than have been sent "
"(outbound_base={}, remote_ctr={})".format(
self._sm_outbound_base,
remote_ctr
)
)
acked = self._sm_unacked_list[:to_drop]
del self._sm_unacked_list[:to_drop]
self._sm_outbound_base = remote_ctr
if acked:
self._logger.debug("%d stanzas acked by remote", len(acked))
for token in acked:
token._set_state(StanzaState.ACKED)
async def send_iq_and_wait_for_reply(self, iq, *, timeout=None):
"""
Send an IQ stanza `iq` and wait for the response. If `timeout` is not
:data:`None`, it must be the time in seconds for which to wait for a
response.
If the response is a ``"result"`` IQ, the value of the
:attr:`~aioxmpp.IQ.payload` attribute is returned. Otherwise,
the exception generated from the :attr:`~aioxmpp.IQ.error`
attribute is raised.
.. seealso::
:meth:`register_iq_response_future` and
:meth:`send_and_wait_for_sent` for other cases raising exceptions.
.. deprecated:: 0.8
This method will be removed in 1.0. Use :meth:`send` instead.
.. versionchanged:: 0.8
On a timeout, :class:`TimeoutError` is now raised instead of
:class:`asyncio.TimeoutError`.
"""
warnings.warn(
r"send_iq_and_wait_for_reply is deprecated and will be removed in"
r" 1.0",
DeprecationWarning,
stacklevel=1,
)
return await self.send(iq, timeout=timeout)
async def send_and_wait_for_sent(self, stanza):
"""
Send the given `stanza` over the given :class:`StanzaStream` `stream`.
.. deprecated:: 0.8
This method will be removed in 1.0. Use :meth:`send` instead.
"""
warnings.warn(
r"send_and_wait_for_sent is deprecated and will be removed in 1.0",
DeprecationWarning,
stacklevel=1,
)
await self._enqueue(stanza)
async def _send_immediately(self, stanza, *, timeout=None, cb=None):
"""
Send a stanza without waiting for the stream to be ready to send
stanzas.
This is only useful from within :class:`aioxmpp.node.Client` before
the stream is fully established.
"""
stanza.autoset_id()
self._logger.debug("sending %r and waiting for it to be sent",
stanza)
if not isinstance(stanza, stanza_.IQ) or stanza.type_.is_response:
if cb is not None:
raise ValueError(
"cb not supported with non-IQ non-request stanzas"
)
await self._enqueue(stanza)
return
# we use the long way with a custom listener instead of a future here
# to ensure that the callback is called synchronously from within the
# queue handling loop.
# we need that to ensure that the strong ordering guarantees reach the
# `cb` function.
fut = asyncio.Future()
def nested_cb(task):
"""
This callback is used to handle awaitables returned by the `cb`.
"""
nonlocal fut
if task.exception() is None:
fut.set_result(task.result())
else:
fut.set_exception(task.exception())
def handler_ok(stanza):
"""
This handler is invoked synchronously by
:meth:`_process_incoming_iq` (via
:class:`aioxmpp.callbacks.TagDispatcher`) for response stanzas
(including error stanzas).
"""
nonlocal fut
if fut.cancelled():
return
if cb is not None:
try:
nested_fut = cb(stanza)
except Exception as exc:
fut.set_exception(exc)
else:
if nested_fut is not None:
nested_fut.add_done_callback(nested_cb)
return
# we can’t even use StanzaErrorAwareListener because we want to
# forward error stanzas to the cb too...
if stanza.type_.is_error:
fut.set_exception(stanza.error.to_exception())
else:
fut.set_result(stanza.payload)
def handler_error(exc):
"""
This handler is invoked synchronously by
:meth:`_process_incoming_iq` (via
:class:`aioxmpp.callbacks.TagDispatcher`) for response errors (
such as parsing errors, connection errors, etc.).
"""
nonlocal fut
if fut.cancelled():
return
fut.set_exception(exc)
listener = callbacks.OneshotTagListener(
handler_ok,
handler_error,
)
listener_tag = (stanza.to, stanza.id_)
self._iq_response_map.add_listener(
listener_tag,
listener,
)
try:
await self._enqueue(stanza)
except Exception:
listener.cancel()
raise
try:
if not timeout:
reply = await fut
else:
try:
reply = await asyncio.wait_for(
fut,
timeout=timeout
)
except asyncio.TimeoutError:
raise TimeoutError
finally:
try:
self._iq_response_map.remove_listener(listener_tag)
except KeyError:
pass
return reply
async def send(self, stanza, timeout=None, *, cb=None):
"""
Deprecated alias of :meth:`aioxmpp.Client.send`.
This is only available on streams owned by a :class:`aioxmpp.Client`.
.. deprecated:: 0.10
"""
raise NotImplementedError(
"only available on streams owned by a Client"
)
@contextlib.contextmanager
def iq_handler(stream, type_, payload_cls, coro, *, with_send_reply=False):
"""
Context manager to temporarily register a coroutine to handle IQ requests
on a :class:`StanzaStream`.
:param stream: Stanza stream to register the coroutine at
:type stream: :class:`StanzaStream`
:param type_: IQ type to react to (must be a request type).
:type type_: :class:`~aioxmpp.IQType`
:param payload_cls: Payload class to react to (subclass of
:class:`~xso.XSO`)
:type payload_cls: :class:`~.XMLStreamClass`
:param coro: Coroutine to register
:param with_send_reply: Whether to pass a function to send the reply
early to `cb`.
:type with_send_reply: :class:`bool`
The coroutine is registered when the context is entered and unregistered
when the context is exited. Running coroutines are not affected by exiting
the context manager.
.. versionadded:: 0.11
The `with_send_reply` argument. See
:meth:`aioxmpp.stream.StanzaStream.register_iq_request_handler` for
more detail.
.. versionadded:: 0.8
"""
stream.register_iq_request_handler(
type_,
payload_cls,
coro,
with_send_reply=with_send_reply,
)
try:
yield
finally:
stream.unregister_iq_request_handler(type_, payload_cls)
@contextlib.contextmanager
def message_handler(stream, type_, from_, cb):
"""
Context manager to temporarily register a callback to handle messages on a
:class:`StanzaStream`.
:param stream: Stanza stream to register the coroutine at
:type stream: :class:`StanzaStream`
:param type_: Message type to listen for, or :data:`None` for a wildcard
match.
:type type_: :class:`~.MessageType` or :data:`None`
:param from_: Sender JID to listen for, or :data:`None` for a wildcard
match.
:type from_: :class:`~aioxmpp.JID` or :data:`None`
:param cb: Callback to register
The callback is registered when the context is entered and unregistered
when the context is exited.
.. versionadded:: 0.8
"""
stream.register_message_callback(
type_,
from_,
cb,
)
try:
yield
finally:
stream.unregister_message_callback(
type_,
from_,
)
@contextlib.contextmanager
def presence_handler(stream, type_, from_, cb):
"""
Context manager to temporarily register a callback to handle presence
stanzas on a :class:`StanzaStream`.
:param stream: Stanza stream to register the coroutine at
:type stream: :class:`StanzaStream`
:param type_: Presence type to listen for.
:type type_: :class:`~.PresenceType`
:param from_: Sender JID to listen for, or :data:`None` for a wildcard
match.
:type from_: :class:`~aioxmpp.JID` or :data:`None`.
:param cb: Callback to register
The callback is registered when the context is entered and unregistered
when the context is exited.
.. versionadded:: 0.8
"""
stream.register_presence_callback(
type_,
from_,
cb,
)
try:
yield
finally:
stream.unregister_presence_callback(
type_,
from_,
)
_Undefined = object()
def stanza_filter(filter_, func, order=_Undefined):
"""
This is a deprecated alias of
:meth:`aioxmpp.callbacks.Filter.context_register`.
.. versionadded:: 0.8
.. deprecated:: 0.9
"""
if order is not _Undefined:
return filter_.context_register(func, order)
else:
return filter_.context_register(func)
aioxmpp/stringprep.py 0000664 0000000 0000000 00000016436 14160146213 0015312 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: stringprep.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
# .
#
########################################################################
"""
Stringprep support
##################
This module implements the Nodeprep (`RFC 6122`_) and Resourceprep
(`RFC 6122`_) stringprep profiles.
.. autofunction:: nodeprep
.. autofunction:: resourceprep
.. autofunction:: nameprep
.. _RFC 3454: https://tools.ietf.org/html/rfc3454
.. _RFC 6122: https://tools.ietf.org/html/rfc6122
"""
import stringprep
from unicodedata import ucd_3_2_0 as unicodedata
_nodeprep_prohibited = frozenset("\"&'/:<>@")
def is_RandALCat(c):
return unicodedata.bidirectional(c) in ("R", "AL")
def is_LCat(c):
return unicodedata.bidirectional(c) == "L"
def check_against_tables(chars, tables):
"""
Perform a check against the table predicates in `tables`. `tables` must be
a reusable iterable containing characteristic functions of character sets,
that is, functions which return :data:`True` if the character is in the
table.
The function returns the first character occurring in any of the tables or
:data:`None` if no character matches.
"""
for c in chars:
if any(in_table(c) for in_table in tables):
return c
return None
def do_normalization(chars):
"""
Perform the stringprep normalization. Operates in-place on a list of
unicode characters provided in `chars`.
"""
chars[:] = list(unicodedata.normalize("NFKC", "".join(chars)))
def check_bidi(chars):
"""
Check proper bidirectionality as per stringprep. Operates on a list of
unicode characters provided in `chars`.
"""
# the empty string is valid, as it cannot violate the RandALCat constraints
if not chars:
return
# first_is_RorAL = unicodedata.bidirectional(chars[0]) in {"R", "AL"}
# if first_is_RorAL:
has_RandALCat = any(is_RandALCat(c) for c in chars)
if not has_RandALCat:
return
has_LCat = any(is_LCat(c) for c in chars)
if has_LCat:
raise ValueError("L and R/AL characters must not occur in the same"
" string")
if not is_RandALCat(chars[0]) or not is_RandALCat(chars[-1]):
raise ValueError("R/AL string must start and end with R/AL character.")
def check_prohibited_output(chars, bad_tables):
"""
Check against prohibited output, by checking whether any of the characters
from `chars` are in any of the `bad_tables`.
Operates in-place on a list of code points from `chars`.
"""
violator = check_against_tables(chars, bad_tables)
if violator is not None:
raise ValueError("Input contains invalid unicode codepoint: "
"U+{:04x}".format(ord(violator)))
def check_unassigned(chars, bad_tables):
"""
Check that `chars` does not contain any unassigned code points as per
the given list of `bad_tables`.
Operates on a list of unicode code points provided in `chars`.
"""
bad_tables = (
stringprep.in_table_a1,)
violator = check_against_tables(chars, bad_tables)
if violator is not None:
raise ValueError("Input contains unassigned code point: "
"U+{:04x}".format(ord(violator)))
def _nodeprep_do_mapping(chars):
i = 0
while i < len(chars):
c = chars[i]
if stringprep.in_table_b1(c):
del chars[i]
else:
replacement = stringprep.map_table_b2(c)
if replacement != c:
chars[i:(i + 1)] = list(replacement)
i += len(replacement)
def nodeprep(string, allow_unassigned=False):
"""
Process the given `string` using the Nodeprep (`RFC 6122`_) profile. In the
error cases defined in `RFC 3454`_ (stringprep), a :class:`ValueError` is
raised.
"""
chars = list(string)
_nodeprep_do_mapping(chars)
do_normalization(chars)
check_prohibited_output(
chars,
(
stringprep.in_table_c11,
stringprep.in_table_c12,
stringprep.in_table_c21,
stringprep.in_table_c22,
stringprep.in_table_c3,
stringprep.in_table_c4,
stringprep.in_table_c5,
stringprep.in_table_c6,
stringprep.in_table_c7,
stringprep.in_table_c8,
stringprep.in_table_c9,
lambda x: x in _nodeprep_prohibited
))
check_bidi(chars)
if not allow_unassigned:
check_unassigned(
chars,
(
stringprep.in_table_a1,
)
)
return "".join(chars)
def _resourceprep_do_mapping(chars):
i = 0
while i < len(chars):
c = chars[i]
if stringprep.in_table_b1(c):
del chars[i]
continue
i += 1
def resourceprep(string, allow_unassigned=False):
"""
Process the given `string` using the Resourceprep (`RFC 6122`_) profile. In
the error cases defined in `RFC 3454`_ (stringprep), a :class:`ValueError`
is raised.
"""
chars = list(string)
_resourceprep_do_mapping(chars)
do_normalization(chars)
check_prohibited_output(
chars,
(
stringprep.in_table_c12,
stringprep.in_table_c21,
stringprep.in_table_c22,
stringprep.in_table_c3,
stringprep.in_table_c4,
stringprep.in_table_c5,
stringprep.in_table_c6,
stringprep.in_table_c7,
stringprep.in_table_c8,
stringprep.in_table_c9,
))
check_bidi(chars)
if not allow_unassigned:
check_unassigned(
chars,
(
stringprep.in_table_a1,
)
)
return "".join(chars)
def nameprep(string, allow_unassigned=False):
"""
Process the given `string` using the Nameprep (`RFC 3491`_) profile. In the
error cases defined in `RFC 3454`_ (stringprep), a :class:`ValueError` is
raised.
"""
chars = list(string)
_nodeprep_do_mapping(chars)
do_normalization(chars)
check_prohibited_output(
chars,
(
stringprep.in_table_c12,
stringprep.in_table_c22,
stringprep.in_table_c3,
stringprep.in_table_c4,
stringprep.in_table_c5,
stringprep.in_table_c6,
stringprep.in_table_c7,
stringprep.in_table_c8,
stringprep.in_table_c9,
))
check_bidi(chars)
if not allow_unassigned:
check_unassigned(
chars,
(
stringprep.in_table_a1,
)
)
return "".join(chars)
aioxmpp/structs.py 0000664 0000000 0000000 00000117745 14160146213 0014631 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: structs.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.structs` --- Simple data holders for common data types
#####################################################################
These classes provide a way to hold structured data which is commonly
encountered in the XMPP realm.
Stanza types
============
.. currentmodule:: aioxmpp
.. autoclass:: IQType
.. autoclass:: MessageType
.. autoclass:: PresenceType
.. autoclass:: ErrorType
Jabber IDs
==========
.. autoclass:: JID(localpart, domain, resource)
.. autofunction:: jid_escape
.. autofunction:: jid_unescape
Presence
========
.. autoclass:: PresenceShow
.. autoclass:: PresenceState
.. currentmodule:: aioxmpp.structs
Languages
=========
.. autoclass:: LanguageTag
.. autoclass:: LanguageRange
.. autoclass:: LanguageMap
Functions for working with language tags
----------------------------------------
.. autofunction:: basic_filter_languages
.. autofunction:: lookup_language
"""
import collections
import enum
import functools
import warnings
from .stringprep import nodeprep, resourceprep, nameprep
_USE_COMPAT_ENUM = True
class CompatibilityMixin:
def __hash__(self):
return hash(self.value)
def __eq__(self, other):
if not _USE_COMPAT_ENUM:
return super().__eq__(other)
if super().__eq__(other) is True:
return True
if self.value == other:
warnings.warn(
"as of aioxmpp 1.0, {} members will not compare equal to "
"their values".format(type(self).__name__),
DeprecationWarning,
stacklevel=2,
)
return True
return False
class ErrorType(CompatibilityMixin, enum.Enum):
"""
Enumeration for the :rfc:`6120` specified stanza error types.
These error types reflect are actually more reflecting the error classes,
but the attribute is called "type" nonetheless. For consistency, we are
calling it "type" here, too.
The following types are specified. The quotations in the member
descriptions are from :rfc:`6120`, Section 8.3.2.
.. attribute:: AUTH
The ``"auth"`` error type:
retry after providing credentials
When converted to an exception, it uses :exc:`~.XMPPAuthError`.
.. attribute:: CANCEL
The ``"cancel"`` error type:
do not retry (the error cannot be remedied)
When converted to an exception, it uses :exc:`~.XMPPCancelError`.
.. attribute:: CONTINUE
The ``"continue"`` error type:
proceed (the condition was only a warning)
When converted to an exception, it uses
:exc:`~.XMPPContinueError`.
.. attribute:: MODIFY
The ``"modify"`` error type:
retry after changing the data sent
When converted to an exception, it uses
:exc:`~.XMPPModifyError`.
.. attribute:: WAIT
The ``"wait"`` error type:
retry after waiting (the error is temporary)
When converted to an exception, it uses (guess what)
:exc:`~.XMPPWaitError`.
:class:`ErrorType` members compare and hash equal to their values. For
example::
assert ErrorType.CANCEL == "cancel"
assert "cancel" == ErrorType.CANCEL
assert hash(ErrorType.CANCEL) == hash("cancel")
.. deprecated:: 0.7
This behaviour will cease with aioxmpp 1.0, and the first assertion will
fail, the second may fail.
Please see the Changelog for :ref:`api-changelog-0.7` for further
details on how to upgrade your code efficiently.
"""
AUTH = "auth"
CANCEL = "cancel"
CONTINUE = "continue"
MODIFY = "modify"
WAIT = "wait"
class MessageType(CompatibilityMixin, enum.Enum):
"""
Enumeration for the :rfc:`6121` specified Message stanza types.
.. seealso::
:attr:`~.Message.type_`
Type attribute of Message stanzas.
Each member has the following meta-information:
.. autoattribute:: is_error
.. autoattribute:: is_request
.. autoattribute:: is_response
.. note::
The :attr:`is_error`, :attr:`is_request` and :attr:`is_response`
meta-information attributes share semantics across :class:`MessageType`,
:class:`PresenceType` and :class:`IQType`. You are encouraged to exploit
this in full duck-typing manner in generic stanza handling code.
The following types are specified. The quotations in the member
descriptions are from :rfc:`6121`, Section 5.2.2.
.. attribute:: NORMAL
The ``"normal"`` Message type:
The message is a standalone message that is sent outside the context
of a one-to-one conversation or groupchat, and to which it is
expected that the recipient will reply. Typically a receiving client
will present a message of type "normal" in an interface that enables
the recipient to reply, but without a conversation history. The
default value of the 'type' attribute is "normal".
Think of it as somewhat similar to "E-Mail via XMPP".
.. attribute:: CHAT
The ``"chat"`` Message type:
The message is sent in the context of a one-to-one chat session.
Typically an interactive client will present a message of type "chat"
in an interface that enables one-to-one chat between the two parties,
including an appropriate conversation history.
.. attribute:: GROUPCHAT
The ``"groupchat"`` Message type:
The message is sent in the context of a multi-user chat environment
[…]. Typically a receiving client will present a message of type
"groupchat" in an interface that enables many-to-many chat between
the parties, including a roster of parties in the chatroom and an
appropriate conversation history.
.. attribute:: HEADLINE
The ``"headline"`` Message type:
The message provides an alert, a notification, or other transient
information to which no reply is expected (e.g., news headlines,
sports updates, near-real-time market data, or syndicated content).
Because no reply to the message is expected, typically a receiving
client will present a message of type "headline" in an interface that
appropriately differentiates the message from standalone messages,
chat messages, and groupchat messages (e.g., by not providing the
recipient with the ability to reply).
Do not confuse this message type with the
:attr:`~.Message.subject` member of Messages!
.. attribute:: ERROR
The ``"error"`` Message type:
The message is generated by an entity that experiences an error when
processing a message received from another entity […]. A client that
receives a message of type "error" SHOULD present an appropriate
interface informing the original sender regarding the nature of the
error.
This is the only message type which is used in direct response to
another message, in the sense that the Stanza ID is preserved in the
response.
:class:`MessageType` members compare and hash equal to their values. For
example::
assert MessageType.CHAT == "chat"
assert "chat" == MessageType.CHAT
assert hash(MessageType.CHAT) == hash("chat")
.. deprecated:: 0.7
This behaviour will cease with aioxmpp 1.0, and the first assertion will
fail, the second may fail.
Please see the Changelog for :ref:`api-changelog-0.7` for further
details on how to upgrade your code efficiently.
"""
NORMAL = "normal"
CHAT = "chat"
GROUPCHAT = "groupchat"
HEADLINE = "headline"
ERROR = "error"
@property
def is_error(self):
"""
True for the :attr:`ERROR` type, false for all others.
"""
return self == MessageType.ERROR
@property
def is_response(self):
"""
True for the :attr:`ERROR` type, false for all others.
This is intended. Request/Response semantics do not really apply for
messages, except that errors are generally in response to other
messages.
"""
return self == MessageType.ERROR
@property
def is_request(self):
"""
False. See :attr:`is_response`.
"""
return False
class PresenceType(CompatibilityMixin, enum.Enum):
"""
Enumeration for the :rfc:`6121` specified Presence stanza types.
.. seealso::
:attr:`~.Presence.type_`
Type attribute of Presence stanzas.
Each member has the following meta-information:
.. autoattribute:: is_error
.. autoattribute:: is_request
.. autoattribute:: is_response
.. autoattribute:: is_presence_state
.. note::
The :attr:`is_error`, :attr:`is_request` and :attr:`is_response`
meta-information attributes share semantics across :class:`MessageType`,
:class:`PresenceType` and :class:`IQType`. You are encouraged to exploit
this in full duck-typing manner in generic stanza handling code.
The following types are specified. The quotes in the member descriptions
are from :rfc:`6121`, Section 4.7.1.
.. attribute:: ERROR
The ``"error"`` Presence type:
An error has occurred regarding processing of a previously sent
presence stanza; if the presence stanza is of type "error", it MUST
include an child element […].
This is the only presence stanza type which is used in direct response
to another presence stanza, in the sense that the Stanza ID is preserved
in the response.
In addition, :attr:`ERROR` presence stanzas may be seen during presence
broadcast if inter-server communication fails.
.. attribute:: PROBE
The ``"probe"`` Presence type:
A request for an entity's current presence; SHOULD be generated only
by a server on behalf of a user.
This should not be seen in client code.
.. attribute:: SUBSCRIBE
The ``"subscribe"`` Presence type:
The sender wishes to subscribe to the recipient's presence.
.. attribute:: SUBSCRIBED
The ``"subscribed"`` Presence type:
The sender has allowed the recipient to receive their presence.
.. attribute:: UNSUBSCRIBE
The ``"unsubscribe"`` Presence type:
The sender is unsubscribing from the receiver's presence.
.. attribute:: UNSUBSCRIBED
The ``"unsubscribed"`` Presence type:
The subscription request has been denied or a previously granted
subscription has been canceled.
.. attribute:: AVAILABLE
The Presence type signalled with an absent type attribute:
The absence of a 'type' attribute signals that the relevant entity is
available for communication […].
.. attribute:: UNAVAILABLE
The ``"unavailable"`` Presence type:
The sender is no longer available for communication.
:class:`PresenceType` members compare and hash equal to their values. For
example::
assert PresenceType.PROBE == "probe"
assert "probe" == PresenceType.PROBE
assert hash(PresenceType.PROBE) == hash("probe")
.. deprecated:: 0.7
This behaviour will cease with aioxmpp 1.0, and the first assertion will
fail, the second may fail.
Please see the Changelog for :ref:`api-changelog-0.7` for further
details on how to upgrade your code efficiently.
"""
ERROR = "error"
PROBE = "probe"
SUBSCRIBE = "subscribe"
SUBSCRIBED = "subscribed"
UNAVAILABLE = "unavailable"
UNSUBSCRIBE = "unsubscribe"
UNSUBSCRIBED = "unsubscribed"
AVAILABLE = None
@property
def is_error(self):
"""
True for the :attr:`ERROR` type, false otherwise.
"""
return self == PresenceType.ERROR
@property
def is_response(self):
"""
True for the :attr:`ERROR` type, false otherwise.
This is intended. Request/Response semantics do not really apply for
presence stanzas, except that errors are generally in response to other
presence stanzas.
"""
return self == PresenceType.ERROR
@property
def is_request(self):
"""
False. See :attr:`is_response`.
"""
return False
@property
def is_presence_state(self):
"""
True for the :attr:`AVAILABLE` and :attr:`UNAVAILABLE` types, false
otherwise.
Useful to discern presence state notifications from meta-stanzas
regarding presence broadcast control.
"""
return (self == PresenceType.AVAILABLE or
self == PresenceType.UNAVAILABLE)
class IQType(CompatibilityMixin, enum.Enum):
"""
Enumeration for the :rfc:`6120` specified IQ stanza types.
.. seealso::
:attr:`~.IQ.type_`
Type attribute of IQ stanzas.
Each member has the following meta-information:
.. autoattribute:: is_error
.. autoattribute:: is_request
.. autoattribute:: is_response
.. note::
The :attr:`is_error`, :attr:`is_request` and :attr:`is_response`
meta-information attributes share semantics across :class:`MessageType`,
:class:`PresenceType` and :class:`IQType`. You are encouraged to exploit
this in full duck-typing manner in generic stanza handling code.
The following types are specified. The quotations in the member
descriptions are from :rfc:`6120`, Section 8.2.3.
.. attribute:: GET
The ``"get"`` IQ type:
The stanza requests information, inquires about what
data is needed in order to complete further operations, etc.
A :attr:`GET` IQ must contain a payload, via the
:attr:`~.IQ.payload` attribute.
.. attribute:: SET
The ``"set"`` IQ type:
The stanza provides data that is needed for an operation to be
completed, sets new values, replaces existing values, etc.
A :attr:`SET` IQ must contain a payload, via the
:attr:`~.IQ.payload` attribute.
.. attribute:: ERROR
The ``"error"`` IQ type:
The stanza reports an error that has occurred regarding processing
or delivery of a get or set request[…].
:class:`~.IQ` objects carrying the :attr:`ERROR` type usually
have the :attr:`~.IQ.error` set to a :class:`~.stanza.Error`
instance describing the details of the error.
The :attr:`~.IQ.payload` attribute may also be set if the sender
of the :attr:`ERROR` was kind enough to include the data which caused
the problem.
.. attribute:: RESULT
The ``"result"`` IQ type:
The stanza is a response to a successful get or set request.
A :attr:`RESULT` IQ may contain a payload with more data.
:class:`IQType` members compare and hash equal to their values. For
example::
assert IQType.GET == "get"
assert "get" == IQType.GET
assert hash(IQType.GET) == hash("get")
.. deprecated:: 0.7
This behaviour will cease with aioxmpp 1.0, and the first assertion will
fail, the second may fail.
Please see the Changelog for :ref:`api-changelog-0.7` for further
details on how to upgrade your code efficiently.
"""
GET = "get"
SET = "set"
ERROR = "error"
RESULT = "result"
@property
def is_error(self):
"""
True for the :attr:`ERROR` type, false otherwise.
"""
return self == IQType.ERROR
@property
def is_request(self):
"""
True for request types (:attr:`GET` and :attr:`SET`), false otherwise.
"""
return self == IQType.GET or self == IQType.SET
@property
def is_response(self):
"""
True for the response types (:attr:`RESULT` and :attr:`ERROR`), false
otherwise.
"""
return self == IQType.RESULT or self == IQType.ERROR
class JID(collections.namedtuple("JID", ["localpart", "domain", "resource"])):
"""
Represent a :term:`Jabber ID (JID) `.
To construct a :class:`JID`, either use the actual constructor, or use the
:meth:`fromstr` class method.
:param localpart: The part in front of the ``@`` of the JID, or
:data:`None` if the localpart shall be omitted (which is different from
it being empty, which would be invalid).
:type localpart: :class:`str` or :data:`None`
:param domain: The domain of the JID. This is the only mandatory part of
a JID.
:type domain: :class:`str`
:param resource: The resource part of the JID or :data:`None` to omit the
resource part.
:type resource: :class:`str` or :data:`None`
:param strict: Enable strict validation
:type strict: :class:`bool`
:raises ValueError: if the JID composed of the given parts is invalid
Construct a JID out of its parts. It validates the parts individually, as
well as the JID as a whole.
If `strict` is false, unassigned codepoints are allowed in any of the parts
of the JID. In the future, other deviations from the respective stringprep
profiles may be allowed, too.
The idea is to use non-`strict` when output is received from outside and
when it is reflected, following the old principle "be conservative in what
you send and liberal in what you receive". Otherwise, strict checking
should be enabled. This brings maximum interoperability.
.. automethod:: fromstr
Information about a JID:
.. attribute:: localpart
The localpart, stringprep’d from the argument to the constructor.
.. attribute:: domain
The domain, stringprep’d from the argument to the constructor.
.. attribute:: resource
The resource, stringprep’d from the argument to the constructor.
.. autoattribute:: is_bare
.. autoattribute:: is_domain
:class:`JID` objects are immutable. To obtain a JID object with a changed
property, use one of the following methods:
.. automethod:: bare
.. automethod:: replace(*, [localpart], [domain], [resource])
"""
__slots__ = []
def __new__(cls, localpart, domain, resource, *, strict=True):
if localpart:
localpart = nodeprep(
localpart,
allow_unassigned=not strict
)
if domain is not None:
domain = nameprep(
domain,
allow_unassigned=not strict
)
if resource:
resource = resourceprep(
resource,
allow_unassigned=not strict
)
if not domain:
raise ValueError("domain must not be empty or None")
if len(domain.encode("utf-8")) > 1023:
raise ValueError("domain too long")
if localpart is not None:
if not localpart:
raise ValueError("localpart must not be empty")
if len(localpart.encode("utf-8")) > 1023:
raise ValueError("localpart too long")
if resource is not None:
if not resource:
raise ValueError("resource must not be empty")
if len(resource.encode("utf-8")) > 1023:
raise ValueError("resource too long")
return super().__new__(cls, localpart, domain, resource)
def replace(self, **kwargs):
"""
Construct a new :class:`JID` object, using the values of the current
JID. Use the arguments to override specific attributes on the new
object.
All arguments are keyword arguments.
:param localpart: Set the local part of the resulting JID.
:param domain: Set the domain of the resulting JID.
:param resource: Set the resource part of the resulting JID.
:raises: See :class:`JID`
:return: A new :class:`JID` object with the corresponding
substitutions performed.
:rtype: :class:`JID`
The attributes of parameters which are omitted are not modified and
copied down to the result.
"""
new_kwargs = {}
strict = kwargs.pop("strict", True)
try:
localpart = kwargs.pop("localpart")
except KeyError:
pass
else:
if localpart:
localpart = nodeprep(
localpart,
allow_unassigned=not strict
)
new_kwargs["localpart"] = localpart
try:
domain = kwargs.pop("domain")
except KeyError:
pass
else:
if not domain:
raise ValueError("domain must not be empty or None")
new_kwargs["domain"] = nameprep(
domain,
allow_unassigned=not strict
)
try:
resource = kwargs.pop("resource")
except KeyError:
pass
else:
if resource:
resource = resourceprep(
resource,
allow_unassigned=not strict
)
new_kwargs["resource"] = resource
if kwargs:
raise TypeError("replace() got an unexpected keyword argument"
" {!r}".format(
next(iter(kwargs))))
return super()._replace(**new_kwargs)
def __str__(self):
result = self.domain
if self.localpart:
result = self.localpart + "@" + result
if self.resource:
result += "/" + self.resource
return result
def bare(self):
"""
Create a copy of the :class:`JID` which is bare.
:return: This JID with the :attr:`resource` set to :data:`None`.
:rtype: :class:`JID`
Return the bare version of this JID as new :class:`JID` object.
"""
return self.replace(resource=None)
@property
def is_bare(self):
"""
:data:`True` if the JID is bare, i.e. has an empty :attr:`resource`
part.
"""
return not self.resource
@property
def is_domain(self):
"""
:data:`True` if the JID is a domain, i.e. if both the :attr:`localpart`
and the :attr:`resource` are empty.
"""
return not self.resource and not self.localpart
@classmethod
def fromstr(cls, s, *, strict=True):
"""
Construct a JID out of a string containing it.
:param s: The string to parse.
:type s: :class:`str`
:param strict: Whether to enable strict parsing.
:type strict: :class:`bool`
:raises: See :class:`JID`
:return: The parsed JID
:rtype: :class:`JID`
See the :class:`JID` class level documentation for the semantics of
`strict`.
"""
nodedomain, sep, resource = s.partition("/")
if not sep:
resource = None
localpart, sep, domain = nodedomain.partition("@")
if not sep:
domain = localpart
localpart = None
return cls(localpart, domain, resource, strict=strict)
@functools.total_ordering
class PresenceShow(CompatibilityMixin, enum.Enum):
"""
Enumeration to support the ``show`` element of presence stanzas.
The enumeration members support total ordering. The order is defined by
relevance and is the following (from lesser to greater): :attr:`XA`,
:attr:`AWAY`, :attr:`NONE`, :attr:`CHAT`, :attr:`DND`. The order is
intended to be used to extract the most relevant resource e.g. in a roster.
.. versionadded:: 0.8
.. attribute:: XA
:annotation: = "xa"
.. epigraph::
The entity or resource is away for an extended period (xa = "eXtended
Away").
-- :rfc:`6121`, Section 4.7.2.1
.. attribute:: EXTENDED_AWAY
:annotation: = "xa"
Alias to :attr:`XA`.
.. attribute:: AWAY
:annotation: = "away"
.. epigraph::
The entity or resource is temporarily away.
-- :rfc:`6121`, Section 4.7.2.1
.. attribute:: NONE
:annotation: = None
Signifies absence of the ``show`` element.
.. attribute:: PLAIN
:annotation: = None
Alias to :attr:`NONE`.
.. attribute:: CHAT
:annotation: = "chat"
.. epigraph::
The entity or resource is actively interested in chatting.
-- :rfc:`6121`, Section 4.7.2.1
.. attribute:: FREE_FOR_CHAT
:annotation: = "chat"
Alias to :attr:`CHAT`.
.. attribute:: DND
:annotation: = "dnd"
.. epigraph::
The entity or resource is busy (dnd = "Do Not Disturb").
-- :rfc:`6121`, Section 4.7.2.1
.. attribute:: DO_NOT_DISTURB
:annotation: = "dnd"
Alias to :attr:`DND`.
"""
XA = "xa"
EXTENDED_AWAY = "xa"
AWAY = "away"
NONE = None
PLAIN = None
CHAT = "chat"
FREE_FOR_CHAT = "chat"
DND = "dnd"
DO_NOT_DISTURB = "dnd"
def __lt__(self, other):
try:
w1 = self._WEIGHTS[self]
w2 = self._WEIGHTS[other]
except KeyError:
return NotImplemented
return w1 < w2
PresenceShow._WEIGHTS = {
PresenceShow.XA: -2,
PresenceShow.AWAY: -1,
PresenceShow.NONE: 0,
PresenceShow.CHAT: 1,
PresenceShow.DND: 2,
}
@functools.total_ordering
class PresenceState:
"""
Hold a presence state of an XMPP resource, as defined by the presence
stanza semantics.
`available` must be a boolean value, which defines whether the resource is
available or not. If the resource is available, `show` may be set to one of
``"dnd"``, ``"xa"``, ``"away"``, :data:`None`, ``"chat"`` (it is a
:class:`ValueError` to attempt to set `show` to a non-:data:`None` value if
`available` is false).
:class:`PresenceState` objects are ordered by their availability and by
their show values. Non-availability sorts lower than availability, and for
available presence states the order is in the order of valid values given
for the `show` above.
.. attribute:: available
As per the argument to the constructor, converted to a :class:`bool`.
.. attribute:: show
As per the argument to the constructor.
.. automethod:: apply_to_stanza
.. automethod:: from_stanza
:class:`PresenceState` objects are immutable.
"""
__slots__ = ["_available", "_show"]
def __init__(self, available=False, show=PresenceShow.NONE):
super().__init__()
if not available and show != PresenceShow.NONE:
raise ValueError("Unavailable state cannot have show value")
if not isinstance(show, PresenceShow):
try:
show = PresenceShow(show)
except ValueError:
raise ValueError("Not a valid show value") from None
else:
warnings.warn(
"as of aioxmpp 1.0, the show argument must use "
"PresenceShow instead of str",
DeprecationWarning,
stacklevel=2
)
self._available = bool(available)
self._show = show
@property
def available(self):
return self._available
@property
def show(self):
return self._show
def __lt__(self, other):
my_key = (self.available, self.show)
try:
other_key = (other.available, other.show)
except AttributeError:
return NotImplemented
return my_key < other_key
def __eq__(self, other):
try:
return (self.available == other.available and
self.show == other.show)
except AttributeError:
return NotImplemented
def __repr__(self):
more = ""
if self.available:
if self.show != PresenceShow.NONE:
more = " available show={!r}".format(self.show)
else:
more = " available"
return "".format(more)
def apply_to_stanza(self, stanza_obj):
"""
Apply the properties of this :class:`PresenceState` to a
:class:`~aioxmpp.Presence` `stanza_obj`. The
:attr:`~aioxmpp.Presence.type_` and
:attr:`~aioxmpp.Presence.show` attributes of the object will be
modified to fit the values in this object.
"""
if self.available:
stanza_obj.type_ = PresenceType.AVAILABLE
else:
stanza_obj.type_ = PresenceType.UNAVAILABLE
stanza_obj.show = self.show
@classmethod
def from_stanza(cls, stanza_obj, strict=False):
"""
Create and return a new :class:`PresenceState` object which inherits
the presence state as advertised in the given
:class:`~aioxmpp.Presence` stanza.
If `strict` is :data:`True`, the value of `show` is strictly checked,
that is, it is required to be :data:`None` if the stanza indicates an
unavailable state.
The default is not to check this.
"""
if not stanza_obj.type_.is_presence_state:
raise ValueError("presence state stanza required")
available = stanza_obj.type_ == PresenceType.AVAILABLE
if not strict:
show = stanza_obj.show if available else PresenceShow.NONE
else:
show = stanza_obj.show
return cls(available=available, show=show)
@functools.total_ordering
class LanguageTag:
"""
Implementation of a language tag. This may be a fully RFC5646 compliant
implementation some day, but for now it is only very simplistic stub.
There is no input validation of any kind.
:class:`LanguageTag` instances compare and hash case-insensitively.
.. automethod:: fromstr
.. autoattribute:: match_str
.. autoattribute:: print_str
"""
__slots__ = ("_tag",)
def __init__(self, *, tag=None):
if not tag:
raise ValueError("tag cannot be empty")
self._tag = tag
@property
def match_str(self):
"""
The string which is used for matching two language tags. This is the
lower-cased version of the :attr:`print_str`.
"""
return self._tag.lower()
@property
def print_str(self):
"""
The stringified language tag.
"""
return self._tag
@classmethod
def fromstr(cls, s):
"""
Create a language tag from the given string `s`.
.. note::
This is a stub implementation which merely refers to the given
string as the :attr:`print_str` and derives the :attr:`match_str`
from that.
"""
return cls(tag=s)
def __str__(self):
return self.print_str
def __eq__(self, other):
try:
return self.match_str == other.match_str
except AttributeError:
return False
def __lt__(self, other):
try:
return self.match_str < other.match_str
except AttributeError:
return NotImplemented
def __le__(self, other):
try:
return self.match_str <= other.match_str
except AttributeError:
return NotImplemented
def __hash__(self):
return hash(self.match_str)
def __repr__(self):
return "<{}.{}.fromstr({!r})>".format(
type(self).__module__,
type(self).__qualname__,
str(self))
class LanguageRange:
"""
Implementation of a language range. This may be a fully RFC4647 compliant
implementation some day, but for now it is only very simplistic stub.
There is no input validation of any kind.
:class:`LanguageRange` instances compare and hash case-insensitively.
.. automethod:: fromstr
.. automethod:: strip_rightmost
.. autoattribute:: match_str
.. autoattribute:: print_str
"""
__slots__ = ("_tag",)
def __init__(self, *, tag=None):
if not tag:
raise ValueError("range cannot be empty")
self._tag = tag
@property
def match_str(self):
"""
The string which is used for matching two language tags. This is the
lower-cased version of the :attr:`print_str`.
"""
return self._tag.lower()
@property
def print_str(self):
"""
The stringified language tag.
"""
return self._tag
@classmethod
def fromstr(cls, s):
"""
Create a language tag from the given string `s`.
.. note::
This is a stub implementation which merely refers to the given
string as the :attr:`print_str` and derives the :attr:`match_str`
from that.
"""
if s == "*":
return cls.WILDCARD
return cls(tag=s)
def __str__(self):
return self.print_str
def __eq__(self, other):
try:
return self.match_str == other.match_str
except AttributeError:
return False
def __hash__(self):
return hash(self.match_str)
def __repr__(self):
return "<{}.{}.fromstr({!r})>".format(
type(self).__module__,
type(self).__qualname__,
str(self))
def strip_rightmost(self):
"""
Strip the rightmost part of the language range. If the new rightmost
part is a singleton or ``x`` (i.e. starts an extension or private use
part), it is also stripped.
Return the newly created :class:`LanguageRange`.
"""
parts = self.print_str.split("-")
parts.pop()
if parts and len(parts[-1]) == 1:
parts.pop()
return type(self).fromstr("-".join(parts))
LanguageRange.WILDCARD = LanguageRange(tag="*")
def basic_filter_languages(languages, ranges):
"""
Filter languages using the string-based basic filter algorithm described in
RFC4647.
`languages` must be a sequence of :class:`LanguageTag` instances which are
to be filtered.
`ranges` must be an iterable which represent the basic language ranges to
filter with, in priority order. The language ranges must be given as
:class:`LanguageRange` objects.
Return an iterator of languages which matched any of the `ranges`. The
sequence produced by the iterator is in match order and duplicate-free. The
first range to match a language yields the language into the iterator, no
other range can yield that language afterwards.
"""
if LanguageRange.WILDCARD in ranges:
yield from languages
return
found = set()
for language_range in ranges:
range_str = language_range.match_str
for language in languages:
if language in found:
continue
match_str = language.match_str
if match_str == range_str:
yield language
found.add(language)
continue
if len(range_str) < len(match_str):
if (match_str[:len(range_str)] == range_str and
match_str[len(range_str)] == "-"):
yield language
found.add(language)
continue
def lookup_language(languages, ranges):
"""
Look up a single language in the sequence `languages` using the lookup
mechanism described in RFC4647. If no match is found, :data:`None` is
returned. Otherwise, the first matching language is returned.
`languages` must be a sequence of :class:`LanguageTag` objects, while
`ranges` must be an iterable of :class:`LanguageRange` objects.
"""
for language_range in ranges:
while True:
try:
return next(iter(basic_filter_languages(
languages,
[language_range])))
except StopIteration:
pass
try:
language_range = language_range.strip_rightmost()
except ValueError:
break
class LanguageMap(dict):
"""
A :class:`dict` subclass specialized for holding :class:`LanugageTag`
instances as keys.
In addition to the interface provided by :class:`dict`, instances of this
class also have the following methods:
.. automethod:: lookup
.. automethod:: any
"""
def lookup(self, language_ranges):
"""
Perform an RFC4647 language range lookup on the keys in the
dictionary. `language_ranges` must be a sequence of
:class:`LanguageRange` instances.
Return the entry in the dictionary with a key as produced by
`lookup_language`. If `lookup_language` does not find a match and the
mapping contains an entry with key :data:`None`, that entry is
returned, otherwise :class:`KeyError` is raised.
"""
keys = list(self.keys())
try:
keys.remove(None)
except ValueError:
pass
keys.sort()
key = lookup_language(keys, language_ranges)
return self[key]
def any(self):
"""
Returns any element from the language map, preferring the :data:`None`
key if it is available.
Guarantees to always return the same element for a map with the same
keys, even if the keys are iterated over in a different order.
"""
if not self:
raise ValueError("any() on empty map")
try:
return self[None]
except KeyError:
return self[min(self)]
# \ is treated specially because it is only escaped if followed by a valid
# escape sequence... that is so weird.
ESCAPABLE_CODEPOINTS = " \"&'/:<>@"
def jid_escape(s):
"""
Return an escaped version of a string for use in a JID localpart.
.. seealso::
:func:`jid_unescape`
for the reverse transformation
:param s: The string to escape for use as localpart.
:type s: :class:`str`
:raise ValueError: If the string starts or ends with a space.
:return: The escaped string.
:rtype: :class:`str`
.. note::
JID Escaping does not allow embedding arbitrary characters in the
localpart. Only a defined subset of characters can be escaped.
Refer to :xep:`0106` for details.
.. note::
No validity check is made on the result. It is assumed that the
result is passed to the :class:`JID` constructor, which will
perform validity checks on its own.
"""
# we first escape all backslashes which need to be escaped
for cp in "\\" + ESCAPABLE_CODEPOINTS:
seq = "\\{:02x}".format(ord(cp))
s = s.replace(seq, "\\5c{:02x}".format(ord(cp)))
# now we escape all the other stuff
for cp in ESCAPABLE_CODEPOINTS:
s = s.replace(cp, "\\{:02x}".format(ord(cp)))
return s
def jid_unescape(localpart):
"""
Un-escape a JID Escaped localpart.
.. seealso::
:func:`jid_escape`
for the reverse transformation
:param localpart: The escaped localpart
:type localpart: :class:`str`
:return: The unescaped localpart.
:rtype: :class:`str`
.. note::
JID Escaping does not allow embedding arbitrary characters in the
localpart. Only a defined subset of characters can be escaped.
Refer to :xep:`0106` for details.
"""
s = localpart
for cp in ESCAPABLE_CODEPOINTS:
s = s.replace("\\{:02x}".format(ord(cp)), cp)
for cp in ESCAPABLE_CODEPOINTS + "\\":
s = s.replace(
"\\5c{:02x}".format(ord(cp)),
"\\{:02x}".format(ord(cp)),
)
return s
aioxmpp/tasks.py 0000664 0000000 0000000 00000016047 14160146213 0014240 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: tasks.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.tasks` -- Manage herds of running coroutines
###########################################################
.. autoclass:: TaskPool
"""
import asyncio
import logging
class TaskPool:
"""
Coroutine worker pool with limits on the maximum number of coroutines.
:param max_tasks: Maximum number of total coroutines running in the pool.
:type max_tasks: positive :class:`int` or :data:`None`
:param logger: Logger to use for diagnostics, defaults to a module-wide
logger
Each coroutine run in the task pool belongs to zero or more groups. Groups
are identified by their hashable *group key*. The structure of the key is
not relevant. Groups are created on-demand. Each coroutine is implicitly
part of the group ``()`` (the empty tuple).
`max_tasks` is the limit on the group ``()`` (the empty tuple). As every
coroutine is running in that group, it is the limit on the total number of
coroutines running in the pool.
When a coroutine exits (either normally or by an exception or
cancellation), it is removed from the pool and the counters for running
coroutines are adapted accordingly.
Controlling limits on groups:
.. automethod:: set_limit
.. automethod:: get_limit
.. automethod:: get_task_count
.. automethod:: clear_limit
Starting and adding coroutines:
.. automethod:: spawn(group, coro_fun, *args, **kwargs)
.. automethod:: add
"""
def __init__(self, *, max_tasks=None, default_limit=None, logger=None):
super().__init__()
if logger is None:
logger = logging.getLogger(__name__)
self._group_limits = {}
self._group_tasks = {}
self.default_limit = default_limit
self.set_limit((), max_tasks)
def set_limit(self, group, new_limit):
"""
Set a new limit on the number of tasks in the `group`.
:param group: Group key of the group to modify.
:type group: hashable
:param new_limit: New limit for the number of tasks running in `group`.
:type new_limit: non-negative :class:`int` or :data:`None`
:raise ValueError: if `new_limit` is non-positive
The limit of tasks for the `group` is set to `new_limit`. If there are
currently more than `new_limit` tasks running in `group`, those tasks
will continue to run, however, the creation of new tasks is inhibited
until the group is below its limit.
If the limit is set to zero, no new tasks can be spawned in the group
at all.
If `new_limit` is negative :class:`ValueError` is raised instead.
If `new_limit` is :data:`None`, the method behaves as if
:meth:`clear_limit` was called for `group`.
"""
if new_limit is None:
self._group_limits.pop(group, None)
return
self._group_limits[group] = new_limit
def clear_limit(self, group):
"""
Clear the limit on the number of tasks in the `group`.
:param group: Group key of the group to modify.
:type group: hashable
The limit on the number of tasks in `group` is removed. If the `group`
currently has no limit, this method has no effect.
"""
self._group_limits.pop(group, None)
def get_limit(self, group):
"""
Return the limit on the number of tasks in the `group`.
:param group: Group key of the group to query.
:type group: hashable
:return: The current limit
:rtype: :class:`int` or :data:`None`
If the `group` currently has no limit set, :data:`None` is returned.
Otherwise, the maximum number of tasks which are allowed to run in the
`group` is returned.
"""
return self._group_limits.get(group)
def get_task_count(self, group):
"""
Return the number of tasks currently running in `group`.
:param group: Group key of the group to query.
:type group: hashable
:return: Number of currently running tasks
:rtype: :class:`int`
"""
return 0
def add(self, groups, coro):
"""
Add a running coroutine in the given pool groups.
:param groups: The groups the coroutine belongs to.
:type groups: :class:`set` of group keys
:param coro: Coroutine to add
:raise RuntimeError: if the limit on any of the groups or the total
limit is exhausted
:rtype: :class:`asyncio.Task`
:return: The task in which the coroutine runs.
Every group must have at least one free slot available for `coro` to be
spawned; if any groups capacity (or the total limit) is exhausted, the
coroutine is not accepted into the pool and :class:`RuntimeError` is
raised.
"""
def spawn(self, __groups, __coro_fun, *args, **kwargs):
"""
Start a new coroutine and add it to the pool atomically.
:param groups: The groups the coroutine belongs to.
:type groups: :class:`set` of group keys
:param coro_fun: Coroutine function to run
:param args: Positional arguments to pass to `coro_fun`
:param kwargs: Keyword arguments to pass to `coro_fun`
:raise RuntimeError: if the limit on any of the groups or the total
limit is exhausted
:rtype: :class:`asyncio.Task`
:return: The task in which the coroutine runs.
Every group must have at least one free slot available for `coro` to be
spawned; if any groups capacity (or the total limit) is exhausted, the
coroutine is not accepted into the pool and :class:`RuntimeError` is
raised.
If the coroutine cannot be added due to limiting, it is not started at
all.
The coroutine is started by calling `coro_fun` with `args` and
`kwargs`.
.. note::
The first two arguments can only be passed positionally, not as
keywords. This is to prevent conflicts with keyword arguments to
`coro_fun`.
"""
# ensure the implicit group is included
__groups = set(__groups) | {()}
return asyncio.ensure_future(__coro_fun(*args, **kwargs))
aioxmpp/testutils.py 0000664 0000000 0000000 00000065473 14160146213 0015162 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: testutils.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
# .
#
########################################################################
"""
This module contains utilities used for testing aioxmpp code. These
utilities themselves are tested, which is meta, but cool.
"""
import asyncio
import collections
import contextlib
import functools
import logging
import os
import time
import unittest
import unittest.mock
from datetime import timedelta
import aioxmpp.callbacks as callbacks
import aioxmpp.xso as xso
import aioxmpp.nonza as nonza
from aioxmpp.utils import etree
logger = logging.getLogger(__name__)
GLOBAL_TIMEOUT_FACTOR = 1.0
_monotonic_info = time.get_clock_info("monotonic")
# this is a fun hack to make things work on windows, where the monotonic
# resolution isn’t that great
GLOBAL_TIMEOUT_FACTOR *= max(_monotonic_info.resolution, 0.0015) / 0.0015
# and now we slap on some extra for travis CI
if os.environ.get("CI") == "true":
GLOBAL_TIMEOUT_FACTOR *= 4
logger.debug("increasing GLOBAL_TIMEOUT_FACTOR for CI")
logger.debug("using GLOBAL_TIMEOUT_FACTOR = %.3f", GLOBAL_TIMEOUT_FACTOR)
def get_timeout(base):
return base * GLOBAL_TIMEOUT_FACTOR
DEFAULT_TIMEOUT = get_timeout(1.0)
def make_protocol_mock():
return unittest.mock.Mock([
"connection_made",
"eof_received",
"connection_lost",
"data_received",
"pause_writing",
"resume_writing",
])
def run_coroutine(coroutine, timeout=DEFAULT_TIMEOUT, loop=None):
if not loop:
loop = asyncio.get_event_loop()
return loop.run_until_complete(
asyncio.wait_for(
coroutine,
timeout=timeout))
def run_coroutine_with_peer(
coroutine,
peer_coroutine,
timeout=1.0,
loop=None):
loop = loop or asyncio.get_event_loop()
local_future = asyncio.ensure_future(coroutine, loop=loop)
remote_future = asyncio.ensure_future(peer_coroutine, loop=loop)
done, pending = loop.run_until_complete(
asyncio.wait(
[
local_future,
remote_future,
],
timeout=timeout,
return_when=asyncio.FIRST_EXCEPTION)
)
if not done:
raise asyncio.TimeoutError("Test timed out")
if pending:
pending_fut = next(iter(pending))
pending_fut.cancel()
fut = next(iter(done))
try:
fut.result()
except: # NOQA: E722
# everything is fine, the other one failed
raise
else:
if pending_fut == remote_future:
raise asyncio.TimeoutError(
"Peer coroutine did not return in time")
else:
raise asyncio.TimeoutError(
"Coroutine under test did not return in time")
if local_future.exception():
# re-throw the error properly
local_future.result()
remote_future.result()
return local_future.result()
def make_listener(instance):
"""
Return a :class:`unittest.mock.Mock` which has children connected to each
:class:`aioxmpp.callbacks.Signal` of `instance`.
The children are named exactly like the signals.
"""
result = unittest.mock.Mock([])
names = {
name
for type_ in type(instance).__mro__
for name in type_.__dict__
}
for name in names:
signal = getattr(instance, name)
if not isinstance(signal, callbacks.AdHocSignal):
continue
cb = unittest.mock.Mock()
setattr(result, name, cb)
cb.return_value = None
signal.connect(cb)
return result
class FilterMock(unittest.mock.Mock):
def __init__(self):
super().__init__([
"register",
"unregister",
"filter",
])
self.context_register = unittest.mock.MagicMock([
"__enter__",
"__exit__",
])
class ConnectedClientMock(unittest.mock.Mock):
on_stream_established = callbacks.Signal()
on_stream_destroyed = callbacks.Signal()
on_stream_suspended = callbacks.Signal()
on_stream_resumed = callbacks.Signal()
on_failure = callbacks.Signal()
on_stopped = callbacks.Signal()
before_stream_established = callbacks.SyncSignal()
negotiation_timeout = timedelta(milliseconds=100)
def __init__(self):
super().__init__([
"stream",
"start",
"stop",
"set_presence",
"local_jid",
"enqueue",
])
self.established = True
self.suspended = False
self.stream_features = nonza.StreamFeatures()
self.stream.on_message_received = callbacks.AdHocSignal()
self.stream.on_presence_received = callbacks.AdHocSignal()
self.stream.on_stream_destroyed = callbacks.AdHocSignal()
self.stream.app_inbound_message_filter = FilterMock()
self.stream.app_inbound_presence_filter = FilterMock()
self.stream.app_outbound_message_filter = FilterMock()
self.stream.app_outbound_presence_filter = FilterMock()
self.stream.service_inbound_message_filter = FilterMock()
self.stream.service_inbound_presence_filter = FilterMock()
self.stream.service_outbound_message_filter = FilterMock()
self.stream.service_outbound_presence_filter = FilterMock()
self.stream.on_stream_destroyed = callbacks.AdHocSignal()
self.stream.send_iq_and_wait_for_reply.side_effect = \
AssertionError("use of deprecated function")
self.stream.send.side_effect = \
AssertionError("use of deprecated function")
self.stream.enqueue.side_effect = \
AssertionError("use of deprecated function")
self.send = CoroutineMock()
self.stream.enqueue_stanza = self.stream.enqueue
self.mock_services = {}
def _get_child_mock(self, **kw):
return unittest.mock.Mock(**kw)
def summon(self, cls):
try:
return self.mock_services[cls]
except KeyError:
raise AssertionError("service class not provisioned in mock")
def make_connected_client():
return ConnectedClientMock()
class CoroutineMock(unittest.mock.Mock):
delay = 0
async def __call__(self, *args, **kwargs):
result = super().__call__(*args, **kwargs)
await asyncio.sleep(self.delay)
return result
class SSLWrapperMock:
"""
Mock for :class:`aioxmpp.ssl_wrapper.STARTTLSableTransportProtocol`.
The *protocol* must be an :class:`XMLStreamMock`, as the
:class:`SSLWrapperMock` depends on some private attributes to ensure the
sequence of events is correct.
"""
# FIXME: this mock is not covered by tests :(
def __init__(self, loop, protocol):
super().__init__()
self._loop = loop
self._protocol = protocol
async def starttls(self, ssl_context=None, post_handshake_callback=None):
"""
Override the STARTTLS sequence. Instead of actually starting a TLS
transport on the existing socket, only make sure that the test expects
starttls to happen now. If so, return fake information on the TLS
transport.
"""
tester = self._protocol._tester
tester.assertFalse(self._protocol._closed)
tester.assertTrue(self._protocol._action_sequence,
"Unexpected client action (no actions left)")
to_recv, to_send = self._protocol._action_sequence.pop(0)
tester.assertTrue(to_recv.startswith("!starttls"),
"Unexpected starttls attempt by the client")
return self, None
def close(self):
pass
class InteractivityMock:
def __init__(self, tester, *, loop=None):
super().__init__()
self._loop = loop or asyncio.get_event_loop()
self._tester = tester
def _check_done(self):
if not self._done.done() and not self._actions:
self._done.set_result(None)
def _pop_and_call_and_catch(self, fun, *args):
@functools.wraps(fun)
def wrap():
try:
self._actions.pop(0)
fun(*args)
except Exception as err:
self._done.set_exception(err)
else:
self._check_done()
self._loop.call_soon(wrap)
def _format_unexpected_action(self, action_name, reason):
return "unexpected {name} ({reason})".format(
name=action_name,
reason=reason
)
def _basic(self, name, action_cls):
self._tester.assertTrue(
self._actions,
self._format_unexpected_action(name, "no actions left"),
)
head = self._actions[0]
self._tester.assertIsInstance(
head, action_cls,
self._format_unexpected_action(name, "expected something else"),
)
self._actions.pop(0)
self._execute_response(head.response)
def _execute_response(self, response):
if response is None:
return
try:
do = response.do
except AttributeError:
# we have the for loop outside this except: block, to have a
# clearer traceback.
if not hasattr(response, "__iter__"):
raise RuntimeError("test specification incorrect: "
"unknown response type: "+repr(response))
else:
self._execute_single(do)
return
for item in response:
self._execute_response(item)
_Write = collections.namedtuple("Write", ["data", "response"])
_STARTTLS = collections.namedtuple("STARTTLS",
["ssl_context",
"post_handshake_callback",
"response"])
GenericTransportAction = collections.namedtuple(
"GenericTransportAction",
["response"])
_LoseConnection = collections.namedtuple("LoseConnection", ["exc"])
class TransportMock(InteractivityMock,
asyncio.ReadTransport,
asyncio.WriteTransport):
class Write(_Write):
def __new__(cls, data, *, response=None):
return _Write.__new__(cls, data=data, response=response)
replace = _Write._replace
class STARTTLS(_STARTTLS):
def __new__(cls, ssl_context, post_handshake_callback, *,
response=None):
return _STARTTLS.__new__(cls,
ssl_context,
post_handshake_callback,
response=response)
replace = _STARTTLS._replace
class Abort(GenericTransportAction):
def __new__(cls, *, response=None):
return GenericTransportAction.__new__(cls, response=response)
replace = GenericTransportAction._replace
class WriteEof(GenericTransportAction):
def __new__(cls, *, response=None):
return GenericTransportAction.__new__(cls, response=response)
replace = GenericTransportAction._replace
class Receive(collections.namedtuple("Receive", ["data"])):
def do(self, transport, protocol):
protocol.data_received(self.data)
class Close(GenericTransportAction):
def __new__(cls, *, response=None):
return GenericTransportAction.__new__(cls, response=response)
replace = GenericTransportAction._replace
class ReceiveEof:
def __repr__(self):
return "ReceiveEof()"
def do(self, transport, protocol):
protocol.eof_received()
class MakeConnection:
def __repr__(self):
return "MakeConnection()"
def do(self, transport, protocol):
transport._connection_made = True
protocol.connection_made(transport)
class LoseConnection(_LoseConnection):
def __new__(cls, exc=None):
return _LoseConnection.__new__(cls, exc)
def do(self, transport, protocol):
protocol.connection_lost(self.exc)
transport._connection_made = False
def __init__(self, tester, protocol, *, with_starttls=False, loop=None):
super().__init__(tester, loop=loop)
self._protocol = protocol
self._actions = None
self._connection_made = False
self._rxd = []
self._queue = asyncio.Queue()
self._with_starttls = with_starttls
def _previously(self):
buf = b"".join(self._rxd)
result = [" (previously: "]
if len(buf) > 100:
result.append("[ {} more bytes ]".format(len(buf) - 100))
buf = buf[-100:]
result.append(str(buf)[1:])
result.append(")")
return "".join(result)
def _format_unexpected_action(self, action_name, reason):
return (
super()._format_unexpected_action(action_name, reason) +
self._previously()
)
def _execute_single(self, do):
do(self, self._protocol)
async def run_test(self, actions, stimulus=None, partial=False):
self._done = asyncio.Future()
self._actions = actions
if not self._connection_made:
self._execute_response(self.MakeConnection())
if stimulus:
if isinstance(stimulus, bytes):
self._execute_response(self.Receive(stimulus))
else:
self._execute_response(stimulus)
while not self._queue.empty() or self._actions:
done, pending = await asyncio.wait(
[
asyncio.ensure_future(self._queue.get()),
self._done
],
return_when=asyncio.FIRST_COMPLETED
)
if self._done not in pending:
# raise if error
self._done.result()
done.remove(self._done)
if done:
value_future = next(iter(done))
action, *args = value_future.result()
if action == "write":
await self._write(*args)
elif action == "write_eof":
await self._write_eof(*args)
elif action == "close":
await self._close(*args)
elif action == "abort":
await self._abort(*args)
elif action == "starttls":
await self._starttls(*args)
else:
assert False
if self._done not in pending:
break
if self._connection_made and not partial:
self._execute_response(self.LoseConnection())
def can_write_eof(self):
return True
async def _write_eof(self):
self._basic("write_eof", self.WriteEof)
async def _write(self, data):
self._tester.assertTrue(
self._actions,
"unexpected write (no actions left)"+self._previously()
)
head = self._actions[0]
self._tester.assertIsInstance(head, self.Write)
expected_data = head.data
if not expected_data.startswith(data):
logging.info("expected: %r", expected_data)
logging.info("got this: %r", data)
self._tester.assertEqual(
expected_data[:len(data)],
bytes(data),
"mismatch of expected and written data"+self._previously()
)
self._rxd.append(data)
expected_data = expected_data[len(data):]
if not expected_data:
self._actions.pop(0)
self._execute_response(head.response)
else:
self._actions[0] = head.replace(data=expected_data)
async def _abort(self):
self._basic("abort", self.Abort)
async def _close(self):
self._basic("close", self.Close)
async def _starttls(self, ssl_context, post_handshake_callback, fut):
self._tester.assertTrue(
self._actions,
self._format_unexpected_action("starttls", "no actions left"),
)
head = self._actions[0]
self._tester.assertIsInstance(
head, self.STARTTLS,
self._format_unexpected_action("starttls",
"expected something else"),
)
self._actions.pop(0)
self._tester.assertEqual(
ssl_context,
head.ssl_context,
"mismatched starttls argument")
self._tester.assertEqual(
post_handshake_callback,
head.post_handshake_callback,
"mismatched starttls argument")
if post_handshake_callback:
try:
await post_handshake_callback(self)
except Exception as exc:
fut.set_exception(exc)
else:
fut.set_result(None)
else:
fut.set_result(None)
self._execute_response(head.response)
def write(self, data):
self._queue.put_nowait(("write", data))
def write_eof(self):
self._queue.put_nowait(("write_eof", ))
def abort(self):
self._queue.put_nowait(("abort", ))
def close(self):
self._queue.put_nowait(("close", ))
def can_starttls(self):
return self._with_starttls
async def starttls(self, ssl_context=None, post_handshake_callback=None):
if not self._with_starttls:
raise RuntimeError("STARTTLS not supported")
fut = asyncio.Future()
self._queue.put_nowait(
("starttls", ssl_context, post_handshake_callback, fut)
)
await fut
class XMLStreamMock(InteractivityMock):
class Receive(collections.namedtuple("Receive", ["obj"])):
def do(self, xmlstream):
if isinstance(self.obj, nonza.StreamFeatures):
for fut in xmlstream._features_futures:
if fut.done():
continue
fut.set_result(self.obj)
return
clsmap = xmlstream.stanza_parser.get_class_map()
cls = type(self.obj)
xmlstream._tester.assertIn(
cls, clsmap,
"no handler registered for {}".format(cls)
)
clsmap[cls](self.obj)
class Fail(collections.namedtuple("Fail", ["exc"])):
def do(self, xmlstream):
xmlstream._exception = self.exc
for fut in xmlstream._error_futures:
if not fut.done():
fut.set_exception(self.exc)
xmlstream.on_closing(self.exc)
class Send(collections.namedtuple("Send", ["obj", "response"])):
def __new__(cls, obj, *, response=None):
return super().__new__(cls, obj, response)
class Reset(collections.namedtuple("Reset", ["response"])):
def __new__(cls, *, response=None):
return super().__new__(cls, response)
class Close(collections.namedtuple("Close", ["response"])):
def __new__(cls, *, response=None):
return super().__new__(cls, response)
class Abort(collections.namedtuple("Abort", ["response"])):
def __new__(cls, *, response=None):
return super().__new__(cls, response)
class Mute(collections.namedtuple("Mute", ["response"])):
def __new__(cls, *, response=None):
return super().__new__(cls, response)
class Unmute(collections.namedtuple("Unmute", ["response"])):
def __new__(cls, *, response=None):
return super().__new__(cls, response)
class STARTTLS(collections.namedtuple("STARTTLS", [
"ssl_context", "post_handshake_callback", "response"])):
def __new__(cls, ssl_context, post_handshake_callback,
*, response=None):
return super().__new__(cls,
ssl_context,
post_handshake_callback,
response)
on_closing = callbacks.Signal()
on_deadtime_soft_limit_tripped = callbacks.Signal()
def __init__(self, tester, *, loop=None):
super().__init__(tester, loop=loop)
self._queue = asyncio.Queue()
self._exception = None
self._closed = False
self.stanza_parser = xso.XSOParser()
self.can_starttls_value = False
self._error_futures = []
self._features_futures = []
def _execute_single(self, do):
do(self)
async def run_test(self, actions, stimulus=None):
self._done = asyncio.Future()
self._actions = actions
self._execute_response(stimulus)
while not self._queue.empty() or self._actions:
done, pending = await asyncio.wait(
[
asyncio.ensure_future(self._queue.get()),
self._done
],
return_when=asyncio.FIRST_COMPLETED
)
if self._done not in pending:
# raise if error
self._done.result()
done.remove(self._done)
if done:
value_future = next(iter(done))
action, *args = value_future.result()
if action == "send":
await self._send_xso(*args)
elif action == "reset":
await self._reset(*args)
elif action == "close":
await self._close(*args)
elif action == "starttls":
await self._starttls(*args)
elif action == "abort":
await self._abort(*args)
elif action == "mute":
await self._mute(*args)
elif action == "unmute":
await self._unmute(*args)
else:
assert False
if self._done not in pending:
break
async def _send_xso(self, obj):
self._tester.assertTrue(
self._actions,
self._format_unexpected_action(
"send_xso("+repr(obj)+")",
"no actions left")
)
head = self._actions[0]
self._tester.assertIsInstance(
head, self.Send,
self._format_unexpected_action(
"send_xso",
"expected something different")
)
t1 = etree.Element("root")
obj.unparse_to_node(t1)
t2 = etree.Element("root")
head.obj.unparse_to_node(t2)
self._tester.assertSubtreeEqual(t1, t2)
self._actions.pop(0)
self._execute_response(head.response)
async def _reset(self):
self._basic("reset", self.Reset)
async def _mute(self):
self._basic("mute", self.Mute)
async def _unmute(self):
self._basic("unmute", self.Unmute)
async def _abort(self):
self._basic("abort", self.Abort)
self._exception = ConnectionError("not connected")
for fut in self._error_futures:
if not fut.done():
fut.set_exception(self._exception)
async def _close(self):
self._basic("close", self.Close)
self._exception = ConnectionError("not connected")
self.on_closing(None)
for fut in self._error_futures:
if not fut.done():
fut.set_exception(self._exception)
async def _starttls(self, ssl_context, post_handshake_callback, fut):
self._tester.assertTrue(
self._actions,
self._format_unexpected_action("starttls", "no actions left"),
)
head = self._actions[0]
self._tester.assertIsInstance(
head, self.STARTTLS,
self._format_unexpected_action("starttls",
"expected something else"),
)
self._actions.pop(0)
self._tester.assertEqual(
ssl_context,
head.ssl_context,
"mismatched starttls argument")
self._tester.assertEqual(
post_handshake_callback,
head.post_handshake_callback,
"mismatched starttls argument")
if post_handshake_callback:
try:
await post_handshake_callback(self.transport)
except Exception as exc:
fut.set_exception(exc)
else:
fut.set_result(None)
else:
fut.set_result(None)
self._execute_response(head.response)
def send_xso(self, obj):
if self._exception:
raise self._exception
self._queue.put_nowait(("send", obj))
def reset(self):
if self._exception:
raise self._exception
self._queue.put_nowait(("reset",))
def abort(self):
if self._exception:
raise self._exception
self._queue.put_nowait(("abort",))
def close(self):
if self._exception:
raise self._exception
self._queue.put_nowait(("close",))
async def starttls(self, ssl_context, post_handshake_callback=None):
if self._exception:
raise self._exception
fut = asyncio.Future()
self._queue.put_nowait(
("starttls", ssl_context, post_handshake_callback, fut)
)
await fut
async def close_and_wait(self):
fut = asyncio.Future()
self.on_closing.connect(fut, self.on_closing.AUTO_FUTURE)
self.close()
try:
await fut
except Exception:
pass
@contextlib.contextmanager
def mute(self):
self._queue.put_nowait(("mute",))
try:
yield
finally:
self._queue.put_nowait(("unmute",))
def can_starttls(self):
return self.can_starttls_value
def error_future(self):
fut = asyncio.Future()
self._error_futures.append(fut)
return fut
def features_future(self):
fut = self.error_future()
self._features_futures.append(fut)
return fut
if not hasattr(unittest.mock.Mock, "assert_not_called"):
def _assert_not_called(m):
if any(not call[0] for call in m.mock_calls):
raise AssertionError(
"expected {!r} to not have been called. "
"Called {} times".format(
m._mock_name or 'mock',
m.call_count)
)
unittest.mock.Mock.assert_not_called = _assert_not_called
del _assert_not_called
aioxmpp/tracking.py 0000664 0000000 0000000 00000040467 14160146213 0014720 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: tracking.py
# This file is part of: aioxmpp
#
# LICENSE
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program. If not, see
# .
#
########################################################################
"""
:mod:`~aioxmpp.tracking` --- Interfaces for high-level message tracking
#######################################################################
This submodule provides interfaces for tracking messages to the recipient. The
actual tracking is not implemented here.
.. versionadded:: 0.5
This module was added in version 0.5.
.. versionchanged:: 0.9
This module was completely rewritten in 0.9.
.. seealso::
Method :meth:`~.muc.Room.send_tracked_message`
implements tracking for messages sent through a MUC.
.. _api-tracking-memory:
General Remarks about Tracking and Memory Consumption
=====================================================
Tracking stanzas costs memory. There are basically two options on how to
implement the management of additional information:
1. Either the tracking stops when the :class:`MessageTracker` is released (i.e.
the last reference to it gets collected).
2. Or the tracking is stopped explicitly by the user.
Option (1) has the appeal that users (applications) do not have to worry about
properly releasing the tracking objects. However, it has the downside that
applications have to keep the :class:`MessageeTracker` instance around.
Remember that connecting to callbacks of an object is *not* enough to keep it
alive.
Option (2) is somewhat like file objects work: in theory, you have to close
them explicitly and manually: if you do not, there is no guarantee when the
file is actually closed. It is thus a somewhat known Python idiom, and also is
more explicit. And it doesn’t break callbacks.
The implementation of :class:`MessageTracker` uses **Option 2**. So you have to
:meth:`MessageTracker.close` all :class:`MessageTracker` objects to ensure that
all tracking resources associated with it are released; this stops any tracking
which is still in progress.
It is strongly recommended that you close message trackers after a timeout. You
can use :meth:`MessageTracker.set_timeout` for that, or manually call
:meth:`MessageTracker.close` as desired.
Tracking implementations
========================
.. autoclass:: BasicTrackingService
Interfaces
==========
.. autoclass:: MessageTracker
.. autoclass:: MessageState
"""
import asyncio
import functools
from datetime import timedelta
from enum import Enum
import aioxmpp.callbacks
import aioxmpp.service
class MessageState(Enum):
"""
Enumeration of possible states for :class:`MessageTracker`. These states
are used to inform using code about the delivery state of a message. See
:class:`MessageTracker` for details.
.. versionchanged:: 0.10
The :attr:`ERROR` state is no longer final. Tracking implementations
may now trump an error state with another state in some cases.
An example would be :xep:`184` message delivery receipts which can
for sure attest an :attr:`DELIVERED_TO_RECIPIENT` state. This is more
useful than an error reply.
.. attribute:: ABORTED
The message has been aborted or dropped in the :class:`~.StanzaStream`
queues. See :class:`~.StanzaToken` and :attr:`MessageTracker.token`.
This is a final state.
.. attribute:: ERROR
An error reply stanza has been received for the stanza which was sent.
This is, in most cases, a final state, but transitions to
:attr:`DELIVERED_TO_RECIPIENT` and :attr:`SEEN_BY_RECIPIENT` are
allowed.
.. attribute:: IN_TRANSIT
The message is still queued for sending or has been sent to the peer
server without stream management.
Depending on the tracking implementation, this may be a final state.
.. attribute:: DELIVERED_TO_SERVER
The message has been delivered to the server and the server acked the
delivery using stream management.
Depending on the tracking implementation, this may be a final state.
.. attribute:: DELIVERED_TO_RECIPIENT
The message has been delivered to the recipient.
Depending on the tracking implementation, this may be a final state.
.. attribute:: SEEN_BY_RECIPIENT
The recipient has marked the message as seen or read. This is a final
state.
"""
IN_TRANSIT = 0
ABORTED = 1
ERROR = 2
DELIVERED_TO_SERVER = 3
DELIVERED_TO_RECIPIENT = 4
SEEN_BY_RECIPIENT = 5
class MessageTracker:
"""
This is the high-level equivalent of the :class:`~.StanzaToken`.
This structure is used by different tracking implementations. The interface
of this class is split in two parts:
1. The public interface for use by applications.
2. The "protected" interface for use by tracking implementations.
:class:`MessageTracker` objects are designed to be drivable from multiple
tracking implementations at once. The idea is that different tracking
implementations can cover different parts of the path a stanza takes: one
can cover the path to the server (by hooking into the events of a
:class:`~.StanzaToken`), the other implementation can use e.g. :xep:`184`
to determine delivery at the target and so on.
Methods and attributes from the "protected" interface are marked by a
leading underscore.
.. autoattribute:: state
.. autoattribute:: response
.. autoattribute:: closed
.. signal:: on_state_changed(new_state, response=None)
Emits when a new state is entered.
:param new_state: The new state of the tracker.
:type new_state: :class:`~.MessageState` member
:param response: A stanza related to the state.
:type response: :class:`~.StanzaBase` or :data:`None`
The is *not* emitted when the tracker is closed.
.. signal:: on_closed()
Emits when the tracker is closed.
.. automethod:: close
.. automethod:: set_timeout
"Protected" interface:
.. automethod:: _set_state
"""
on_closed = aioxmpp.callbacks.Signal()
on_state_changed = aioxmpp.callbacks.Signal()
def __init__(self):
super().__init__()
self._state = MessageState.IN_TRANSIT
self._response = None
self._closed = False
@property
def state(self):
"""
The current state of the tracking. Read-only.
"""
return self._state
@property
def response(self):
"""
A stanza which is relevant to the current state. For
:attr:`.MessageState.ERROR`, this will generally be a
:class:`.MessageType.ERROR` stanza. For other states, this is either
:data:`None` or another stanza depending on the tracking
implementation.
"""
return self._response
@property
def closed(self):
"""
Boolean indicator whether the tracker is closed.
.. seealso::
:meth:`close` for details.
"""
return self._closed
def close(self):
"""
Close the tracking, clear all references to the tracker and release all
tracking-related resources.
This operation is idempotent. It does not change the :attr:`state`, but
:attr:`closed` turns :data:`True`.
The :meth:`on_closed` event is only fired on the first call to
:meth:`close`.
"""
if self._closed:
return
self._closed = True
self.on_closed()
def set_timeout(self, timeout):
"""
Automatically close the tracker after `timeout` has elapsed.
:param timeout: The timeout after which the tracker is closed
automatically.
:type timeout: :class:`numbers.Real` or :class:`datetime.timedelta`
If the `timeout` is not a :class:`datetime.timedelta` instance, it is
assumed to be given as seconds.
The timeout cannot be cancelled after it has been set. It starts at the
very moment :meth:`set_timeout` is called.
"""
loop = asyncio.get_event_loop()
if isinstance(timeout, timedelta):
timeout = timeout.total_seconds()
loop.call_later(timeout, self.close)
# "Protected" Interface
def _set_state(self, new_state, response=None):
"""
Set the state of the tracker.
:param new_state: The new state of the tracker.
:type new_state: :class:`~.MessageState` member
:param response: A stanza related to the new state.
:type response: :class:`~.StanzaBase` or :data:`None`
:raise ValueError: if a forbidden state transition is attempted.
:raise RuntimeError: if the tracker is closed.
The state of the tracker is set to the `new_state`. The
:attr:`response` is also overridden with the new value, no matter if the
new or old value is :data:`None` or not. The :meth:`on_state_changed`
event is emitted.
The following transitions are forbidden and attempting to perform them
will raise :class:`ValueError`:
* any state -> :attr:`~.MessageState.IN_TRANSIT`
* :attr:`~.MessageState.DELIVERED_TO_RECIPIENT` ->
:attr:`~.MessageState.DELIVERED_TO_SERVER`
* :attr:`~.MessageState.SEEN_BY_RECIPIENT` ->
:attr:`~.MessageState.DELIVERED_TO_RECIPIENT`
* :attr:`~.MessageState.SEEN_BY_RECIPIENT` ->
:attr:`~.MessageState.DELIVERED_TO_SERVER`
* :attr:`~.MessageState.ABORTED` -> any state
* :attr:`~.MessageState.ERROR` -> any state
If the tracker is already :meth:`close`\\ -d, :class:`RuntimeError` is
raised. This check happens *before* a test is made whether the
transition is valid.
This method is part of the "protected" interface.
"""
if self._closed:
raise RuntimeError("message tracker is closed")
# reject some transitions as documented
if (self._state == MessageState.ABORTED or
new_state == MessageState.IN_TRANSIT or
(self._state == MessageState.ERROR and
new_state == MessageState.DELIVERED_TO_SERVER) or
(self._state == MessageState.ERROR and
new_state == MessageState.ABORTED) or
(self._state == MessageState.DELIVERED_TO_RECIPIENT and
new_state == MessageState.DELIVERED_TO_SERVER) or
(self._state == MessageState.SEEN_BY_RECIPIENT and
new_state == MessageState.DELIVERED_TO_SERVER) or
(self._state == MessageState.SEEN_BY_RECIPIENT and
new_state == MessageState.DELIVERED_TO_RECIPIENT)):
raise ValueError(
"message tracker transition from {} to {} not allowed".format(
self._state,
new_state
)
)
self._state = new_state
self._response = response
self.on_state_changed(self._state, self._response)
class BasicTrackingService(aioxmpp.service.Service):
"""
Error handling and :class:`~.StanzaToken`\\ -based tracking for messages.
This service provides the most basic tracking of message stanzas. It can be
combined with other forms of tracking.
Specifically, the stanza is tracked using the means of
:class:`~.StanzaToken`, that is, until it is acknowledged by the server. In
addition, error stanzas in reply to the message are also tracked (but they
do not override states occurring after
:attr:`~.MessageState.DELIVERED_TO_SERVER`).
Tracking stanzas:
.. automethod:: send_tracked
.. automethod:: attach_tracker
"""
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self._trackers = {}
@aioxmpp.service.inbound_message_filter
def _inbound_message_filter(self, message):
try:
if message.type_ != aioxmpp.MessageType.ERROR:
return message
except AttributeError:
return message
try:
key = message.from_.bare(), message.id_
except AttributeError:
return message
try:
tracker = self._trackers.pop(key)
except KeyError:
return message
if tracker.state == MessageState.DELIVERED_TO_RECIPIENT:
return
if tracker.state == MessageState.SEEN_BY_RECIPIENT:
return
tracker._set_state(MessageState.ERROR, message)
def _tracker_closed(self, key):
self._trackers.pop(key, None)
def _stanza_sent(self, tracker, token, fut):
# FIXME: look into whether this is correct, and if it is, document why:
#
# - CancelledError does not lead to ABORTED
# - why it makes sense to channel all *other* exceptions into
# ABORTED state
try:
fut.result()
except asyncio.CancelledError:
return
except: # NOQA: E722
next_state = MessageState.ABORTED
else:
next_state = MessageState.DELIVERED_TO_SERVER
try:
tracker._set_state(next_state)
except ValueError:
pass
def send_tracked(self, stanza, tracker):
"""
Send a message stanza with tracking.
:param stanza: Message stanza to send.
:type stanza: :class:`aioxmpp.Message`
:param tracker: Message tracker to use.
:type tracker: :class:`~.MessageTracker`
:rtype: :class:`~.StanzaToken`
:return: The token used to send the stanza.
If `tracker` is :data:`None`, a new :class:`~.MessageTracker` is
created.
This configures tracking for the stanza as if by calling
:meth:`attach_tracker` with a `token` and sends the stanza through the
stream.
.. seealso::
:meth:`attach_tracker`
can be used if the stanza cannot be sent (e.g. because it is a
carbon-copy) or has already been sent.
"""
token = self.client.enqueue(stanza)
self.attach_tracker(stanza, tracker, token)
return token
def attach_tracker(self, stanza, tracker=None, token=None):
"""
Configure tracking for a stanza without sending it.
:param stanza: Message stanza to send.
:type stanza: :class:`aioxmpp.Message`
:param tracker: Message tracker to use.
:type tracker: :class:`~.MessageTracker` or :data:`None`
:param token: Optional stanza token for more fine-grained tracking.
:type token: :class:`~.StanzaToken`
:rtype: :class:`~.MessageTracker`
:return: The message tracker.
If `tracker` is :data:`None`, a new :class:`~.MessageTracker` is
created.
If `token` is not :data:`None`, updates to the stanza `token` are
reflected in the `tracker`.
If an error reply is received, the tracker will enter
:class:`~.MessageState.ERROR` and the error will be set as
:attr:`~.MessageTracker.response`.
You should use :meth:`send_tracked` if possible. This method however is
very useful if you need to track carbon copies of sent messages, as a
stanza token is not available here and re-sending the message to obtain
one is generally not desirable ☺.
"""
if tracker is None:
tracker = MessageTracker()
stanza.autoset_id()
key = stanza.to.bare(), stanza.id_
self._trackers[key] = tracker
tracker.on_closed.connect(
functools.partial(self._tracker_closed, key)
)
if token is not None:
token.future.add_done_callback(
functools.partial(
self._stanza_sent,
tracker,
token,
)
)
return tracker
aioxmpp/utils.py 0000664 0000000 0000000 00000044401 14160146213 0014246 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.utils` --- Internal utils
========================================
Miscellaneous utilities used throughout the aioxmpp codebase.
.. data:: namespaces
Collects all the namespaces from the various standards. Each namespace
is given a shortname and its value is the namespace string.
.. note:: This is intended to be promoted to the public API in a
future release. Third-party users should not assign
short-names for their own namespaces here, but instead
use a separate instance of :class:`Namespaces`.
.. autoclass:: Namespaces
.. autofunction:: gather_reraise_multi
.. autofunction:: mkdir_exist_ok
.. autofunction:: to_nmtoken
.. autoclass:: LazyTask
.. autoclass:: AlivenessMonitor
.. autodecorator:: magicmethod
.. autofunction:: proxy_property
"""
import asyncio
import base64
import contextlib
import time
import types
import aioxmpp.callbacks
import aioxmpp.errors
import lxml.etree as etree
__all__ = [
"etree",
"namespaces",
]
class Namespaces:
"""
Manage short-hands for XML namespaces.
Instances of this class may be used to assign mnemonic short-hands
to XML namespaces, for example:
.. code-block:: python
namespaces = Namespaces()
namespaces.foo = "urn:example:foo"
namespaces.bar = "urn:example:bar"
The class ensures that
1. only one short-hand is bound to each namespace, so continuing the
example, the following raises :class:`ValueError`:
.. code-block:: python
namespace.example_foo = "urn:example:foo"
2. no short-hand is redefined to point to a different namespace,
continuing the example, the following raises
:class:`ValueError`:
.. code-block:: python
namespaces.foo = "urn:example:foo:2"
3. deleting a short-hand is prohibited, the following raises
:class:`AttributeError`:
.. code-block:: python
del namespaces.foo
The defined short-hands MUST NOT start with an underscore.
.. note:: This is intended to be promoted to the public API in a
future release.
"""
def __init__(self):
self._all_namespaces = {}
def __setattr__(self, attr, value):
if not attr.startswith("_"):
try:
existing_attr = self._all_namespaces[value]
if attr != existing_attr:
raise ValueError(
"namespace {} already defined as {}".format(
value,
existing_attr,
)
)
except KeyError:
try:
if getattr(self, attr) != value:
raise ValueError("inconsistent namespace redefinition")
except AttributeError:
pass
self._all_namespaces[value] = attr
super().__setattr__(attr, value)
def __delattr__(self, attr):
if not attr.startswith("_"):
raise AttributeError("deleting short-hands is prohibited")
super().__delattr__(attr)
namespaces = Namespaces()
namespaces.xmlstream = "http://etherx.jabber.org/streams"
namespaces.client = "jabber:client"
namespaces.starttls = "urn:ietf:params:xml:ns:xmpp-tls"
namespaces.sasl = "urn:ietf:params:xml:ns:xmpp-sasl"
namespaces.stanzas = "urn:ietf:params:xml:ns:xmpp-stanzas"
namespaces.streams = "urn:ietf:params:xml:ns:xmpp-streams"
namespaces.stream_management = "urn:xmpp:sm:3"
namespaces.aioxmpp = "https://zombofant.net/xmlns/aioxmpp"
namespaces.aioxmpp_test = "https://zombofant.net/xmlns/aioxmpp#test"
namespaces.aioxmpp_internal = "https://zombofant.net/xmlns/aioxmpp#internal"
namespaces.xml = "http://www.w3.org/XML/1998/namespace"
@contextlib.contextmanager
def background_task(coro, logger):
def log_result(task):
try:
result = task.result()
except asyncio.CancelledError:
logger.debug("background task terminated by CM exit: %r",
task)
except: # NOQA
logger.error("background task failed: %r",
task,
exc_info=True)
else:
if result is not None:
logger.info("background task (%r) returned a value: %r",
task,
result)
task = asyncio.ensure_future(coro)
task.add_done_callback(log_result)
try:
yield
finally:
task.cancel()
class magicmethod:
"""
Decorator for methods that makes them work as instance *and* class
method. The first argument will be the class if called on the
class and the instance when called on the instance.
"""
__slots__ = ("_f",)
def __init__(self, f):
super().__init__()
self._f = f
def __get__(self, instance, class_):
if instance is None:
return types.MethodType(self._f, class_)
return types.MethodType(self._f, instance)
def mkdir_exist_ok(path):
"""
Create a directory (including parents) if it does not exist yet.
:param path: Path to the directory to create.
:type path: :class:`pathlib.Path`
Uses :meth:`pathlib.Path.mkdir`; if the call fails with
:class:`FileNotFoundError` and `path` refers to a directory, it is treated
as success.
"""
try:
path.mkdir(parents=True)
except FileExistsError:
if not path.is_dir():
raise
class LazyTask(asyncio.Future):
"""
:class:`asyncio.Future` subclass which spawns a coroutine when it is first
awaited.
:param coroutine_function: The coroutine function to invoke.
:param args: Arguments to pass to `coroutine_function`.
:class:`LazyTask` objects are awaitable. When the first attempt to await
them is made, the `coroutine_function` is started with the given `args` and
the result is awaited. Any further awaits on the :class:`LazyTask` will
await the same coroutine.
"""
def __init__(self, coroutine_function, *args):
super().__init__()
self.__coroutine_function = coroutine_function
self.__args = args
self.__task = None
def add_done_callback(self, cb, *args):
self.__start_task()
return super().add_done_callback(cb, *args)
def __start_task(self):
if self.__task is None:
self.__task = asyncio.ensure_future(
self.__coroutine_function(*self.__args)
)
self.__task.add_done_callback(self.__task_done)
def __task_done(self, task):
if task.exception():
self.set_exception(task.exception())
else:
self.set_result(task.result())
def __iter__(self):
self.__start_task()
return iter(self.__task)
if hasattr(asyncio.Future, "__await__"):
def __await__(self):
self.__start_task()
if hasattr(self.__task, "__await__"):
return self.__task.__await__()
else:
return super().__await__()
async def gather_reraise_multi(*fut_or_coros, message="gather_reraise_multi"):
"""
Wrap all the arguments `fut_or_coros` in futures with
:func:`asyncio.ensure_future` and wait until all of them are finish or
fail.
:param fut_or_coros: the futures or coroutines to wait for
:type fut_or_coros: future or coroutine
:param message: the message included with the raised
:class:`aioxmpp.errors.GatherError` in the case of failure.
:type message: :class:`str`
:returns: the list of the results of the arguments.
:raises aioxmpp.errors.GatherError: if any of the futures or
coroutines fail.
If an exception was raised, reraise all exceptions wrapped in a
:class:`aioxmpp.errors.GatherError` with the message set to
`message`.
.. note::
This is similar to the standard function
:func:`asyncio.gather`, but avoids the in-band signalling of
raised exceptions as return values, by raising exceptions bundled
as a :class:`aioxmpp.errors.GatherError`.
.. note::
Use this function only if you are either
a) not interested in the return values, or
b) only interested in the return values if all futures are
successful.
"""
todo = [asyncio.ensure_future(fut_or_coro) for fut_or_coro in fut_or_coros]
if not todo:
return []
await asyncio.wait(todo)
results = []
exceptions = []
for fut in todo:
if fut.exception() is not None:
exceptions.append(fut.exception())
else:
results.append(fut.result())
if exceptions:
raise aioxmpp.errors.GatherError(message, exceptions)
return results
def to_nmtoken(rand_token):
"""
Convert a (random) token given as raw :class:`bytes` or
:class:`int` to a valid NMTOKEN
.
The encoding as a valid nmtoken is injective, ensuring that two
different inputs cannot yield the same token. Nevertheless, it is
recommended to only use one kind of inputs (integers or bytes of a
consistent length) in one context.
"""
if isinstance(rand_token, int):
rand_token = rand_token.to_bytes(
(rand_token.bit_length() + 7) // 8,
"little"
)
e = base64.urlsafe_b64encode(rand_token).rstrip(b"=").decode("ascii")
return ":" + e
if isinstance(rand_token, bytes):
e = base64.urlsafe_b64encode(rand_token).rstrip(b"=").decode("ascii")
if not e:
e = "."
return e
raise TypeError("rand_token musst be a bytes or int instance")
class AlivenessMonitor:
"""
Monitors aliveness of a data stream.
.. versionadded:: 0.10
:param loop: The event loop to operate the checks in.
:type loop: :class:`asyncio.BaseEventLoop`
This class is a utility class to implement a traffic-efficient timeout
mechanism.
This class can be used to monitor a stream if it is possible to ask the
remote party send some data (classic ping mechanism). It works particularly
well if the remote party will send data even without being specifically
asked for it (saves traffic).
It is notabily not a mean to enforce a maximum acceptable round-trip time.
Quite on the contrary, this class was designed specifically to provide a
reliable experience even *without* an upper bound on the round-trip time.
To use this class, the using code has to configure the
:attr:`deadtime_soft_limit`, :attr:`deadtime_hard_limit`, subscribe to the
signals below and call :meth:`notify_received` whenever *any* data is
received from the peer.
There exist two timers, the *soft limit timer* and the *hard limit timer*
(configured by the respective limit attributes). When the class is
instantiated, the timers are reset to zero (*but* they start running
immediately!).
When a timer exceeds its respective limit, its corresponding signal is
emitted. The signal is not re-emitted until the next call of
:meth:`notfiy_received`.
When :meth:`notify_received` is called, the timers are reset to zero.
This allows for the following features:
- Keep a stream alive on a high-latency link as long as data is pouring in.
This is very useful on saturated (mobile) links. Imagine firing a MAM
query and a bunch of avatar requests after connecting. With naive ping
logic, this would easily cause the stream to be considered dead because
the round-trip time is extremely high.
However, with this logic, as long as data is pouring in, the stream is
considered alive.
- When using the soft limit to trigger a ping and a reasonable difference
between the soft and the hard limit timeout, this logic gracefully
reverts to classic pinging when no traffic is seen on the stream.
- If the peer is pinging us in an interval which works for us (i.e. is less
than the soft limit), we don’t need to ping the peer; no extra logic
required.
This mechanism is used by :class:`aioxmpp.protocol.XMLStream`.
.. signal:: on_deadtime_soft_limit_tripped()
Emits when the :attr:`deadtime_soft_limit` expires.
.. signal:: on_deadtime_hard_limit_tripped()
Emits when the :attr:`deadtime_hard_limit` expires.
.. automethod:: notify_received
.. autoattribute:: deadtime_soft_limit
:annotation: = None
.. autoattribute:: deadtime_hard_limit
:annotation: = None
"""
on_deadtime_soft_limit_tripped = aioxmpp.callbacks.Signal()
on_deadtime_hard_limit_tripped = aioxmpp.callbacks.Signal()
def __init__(self, loop):
super().__init__()
self._loop = loop
self._soft_limit = None
self._soft_limit_timer = None
self._soft_limit_tripped = False
self._hard_limit = None
self._hard_limit_timer = None
self._hard_limit_tripped = False
self._reset_trips()
def _trip_soft_limit(self):
if self._soft_limit_tripped:
return
self._soft_limit_tripped = True
self.on_deadtime_soft_limit_tripped()
def _trip_hard_limit(self):
if self._hard_limit_tripped:
return
self._hard_limit_tripped = True
self.on_deadtime_hard_limit_tripped()
def _retrigger_timers(self):
now = time.monotonic()
if self._soft_limit_timer is not None:
self._soft_limit_timer.cancel()
self._soft_limit_timer = None
if self._soft_limit is not None:
self._soft_limit_timer = self._loop.call_later(
self._soft_limit.total_seconds() - (now - self._last_rx),
self._trip_soft_limit
)
if self._hard_limit_timer is not None:
self._hard_limit_timer.cancel()
self._hard_limit_timer = None
if self._hard_limit is not None:
self._hard_limit_timer = self._loop.call_later(
self._hard_limit.total_seconds() - (now - self._last_rx),
self._trip_hard_limit
)
def _reset_trips(self):
self._soft_limit_tripped = False
self._hard_limit_tripped = False
self._last_rx = time.monotonic()
def notify_received(self):
"""
Inform the aliveness check that something was received.
Resets the internal soft/hard limit timers.
"""
self._reset_trips()
self._retrigger_timers()
@property
def deadtime_soft_limit(self):
"""
Soft limit for the timespan in which no data is received in the stream.
When the last data reception was longer than this limit ago,
:meth:`on_deadtime_soft_limit_tripped` emits once.
Changing this makes the monitor re-check its limits immediately. Setting
this to :data:`None` disables the soft limit check.
Note that setting this to a value greater than
:attr:`deadtime_hard_limit` means that the hard limit will fire first.
"""
return self._soft_limit
@deadtime_soft_limit.setter
def deadtime_soft_limit(self, value):
if self._soft_limit_timer is not None:
self._soft_limit_timer.cancel()
self._soft_limit = value
self._retrigger_timers()
@property
def deadtime_hard_limit(self):
"""
Hard limit for the timespan in which no data is received in the stream.
When the last data reception was longer than this limit ago,
:meth:`on_deadtime_hard_limit_tripped` emits once.
Changing this makes the monitor re-check its limits immediately. Setting
this to :data:`None` disables the hard limit check.
Note that setting this to a value less than
:attr:`deadtime_soft_limit` means that the hard limit will fire first.
"""
return self._hard_limit
@deadtime_hard_limit.setter
def deadtime_hard_limit(self, value):
if self._hard_limit_timer is not None:
self._hard_limit_timer.cancel()
self._hard_limit = value
self._retrigger_timers()
def proxy_property(owner_attr, member_attr, *,
readonly=False,
allow_delete=False):
"""
Proxy a property of a member.
:param owner_attr: The name of the attribute at which the member can
be found.
:type owner_attr: :class:`str`
:param member_attr: The name of the member property to proxy.
:type member_attr: :class:`str`
:param readonly: If true, the proxied property will not be writable, even
if the original property is writable.
:type readonly: :class:`bool`
:param allow_delete: If true, the ``del`` operator is allowed on the
proxy property and will be forwarded to the target property.
:type allow_delete: :class:`bool`
.. versionadded:: 0.11.0
This can be useful when combining classes via composition instead of
inheritance.
It is not necessary to set `readonly` to true if the target property is
already readonly.
"""
ga = getattr
sa = setattr
da = delattr
def getter(instance):
return ga(ga(instance, owner_attr), member_attr)
if readonly:
setter = None
else:
def setter(instance, value):
return sa(ga(instance, owner_attr), member_attr, value)
if allow_delete:
def deleter(instance):
da(ga(instance, owner_attr), member_attr)
else:
deleter = None
return property(
getter,
setter,
deleter,
)
aioxmpp/vcard/ 0000775 0000000 0000000 00000000000 14160146213 0013630 5 ustar 00root root 0000000 0000000 aioxmpp/vcard/__init__.py 0000664 0000000 0000000 00000002443 14160146213 0015744 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.vcard` --- vcard-temp support (:xep:`0054`)
##########################################################
This subpackage provides minimal support for setting and retrieving
vCard as per :xep:`0054`.
.. versionadded:: 0.10
We supply the service:
.. autoclass:: VCardService()
.. currentmodule:: aioxmpp.vcard.xso
The VCards are exposed as:
.. autoclass:: VCard()
"""
from .service import VCardService # NOQA: F401
aioxmpp/vcard/service.py 0000664 0000000 0000000 00000005257 14160146213 0015653 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.service as service
from . import xso as vcard_xso
class VCardService(service.Service):
"""
Service for handling vcard-temp.
.. automethod:: get_vcard
.. automethod:: set_vcard
"""
async def get_vcard(self, jid=None):
"""
Get the vCard stored for the jid `jid`. If `jid` is
:data:`None` get the vCard of the connected entity.
:param jid: the object to retrieve.
:returns: the stored vCard.
We mask a :class:`XMPPCancelError` in case it is
``feature-not-implemented`` or ``item-not-found`` and return
an empty vCard, since this can be understood to be semantically
equivalent.
"""
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.GET,
to=jid,
payload=vcard_xso.VCard(),
)
try:
return await self.client.send(iq)
except aioxmpp.XMPPCancelError as e:
if e.condition in (
aioxmpp.ErrorCondition.FEATURE_NOT_IMPLEMENTED,
aioxmpp.ErrorCondition.ITEM_NOT_FOUND):
return vcard_xso.VCard()
else:
raise
async def set_vcard(self, vcard, jid=None):
"""
Store the vCard `vcard` for the connected entity.
:param vcard: the vCard to store.
.. note::
`vcard` should always be derived from the result of
`get_vcard` to preserve the elements of the vcard the
client does not modify.
.. warning::
It is in the responsibility of the user to supply valid
vcard data as per :xep:`0054`.
"""
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=vcard,
to=jid,
)
await self.client.send(iq)
aioxmpp/vcard/xso.py 0000664 0000000 0000000 00000006765 14160146213 0015031 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import base64
import lxml.etree as etree
import aioxmpp
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0054 = "vcard-temp"
@aioxmpp.IQ.as_payload_class
class VCard(xso.XSO):
"""
The container for vCard data as per :xep:`vcard-temp <54>`.
.. attribute:: elements
The raw elements of the vCard (as etree).
The following methods are defined to access and modify certain
entries of the vCard in a highlevel manner:
.. automethod:: get_photo_data
.. automethod:: set_photo_data
.. automethod:: clear_photo_data
"""
TAG = (namespaces.xep0054, "vCard")
elements = xso.Collector()
def get_photo_mime_type(self):
"""
Get the mime type of the photo stored in the vCard.
:returns: the MIME type of the photo as :class:`str` or :data:`None`.
"""
mime_type = self.elements.xpath("/ns0:vCard/ns0:PHOTO/ns0:TYPE/text()",
namespaces={"ns0": namespaces.xep0054})
if mime_type:
return mime_type[0]
return None
def get_photo_data(self):
"""
Get the photo stored in the vCard.
:returns: the photo as :class:`bytes` or :data:`None`.
"""
photo = self.elements.xpath("/ns0:vCard/ns0:PHOTO/ns0:BINVAL/text()",
namespaces={"ns0": namespaces.xep0054})
if photo:
return base64.b64decode(photo[0])
return None
def set_photo_data(self, mime_type, data):
"""
Set the photo stored in the vCard.
:param mime_type: the MIME type of the image data
:param data: the image data as :class:`bytes`
"""
res = self.elements.xpath("/ns0:vCard/ns0:PHOTO",
namespaces={"ns0": namespaces.xep0054})
if res:
photo = res[0]
photo.clear()
else:
photo = etree.SubElement(
self.elements,
etree.QName(namespaces.xep0054, "PHOTO")
)
binval = etree.SubElement(
photo,
etree.QName(namespaces.xep0054, "BINVAL")
)
binval.text = base64.b64encode(data)
type_ = etree.SubElement(
photo,
etree.QName(namespaces.xep0054, "TYPE")
)
type_.text = mime_type
def clear_photo_data(self):
"""
Remove the photo stored in the vCard.
"""
res = self.elements.xpath("/ns0:vCard/ns0:PHOTO",
namespaces={"ns0": namespaces.xep0054})
for to_remove in res:
self.elements.remove(to_remove)
aioxmpp/version/ 0000775 0000000 0000000 00000000000 14160146213 0014216 5 ustar 00root root 0000000 0000000 aioxmpp/version/__init__.py 0000664 0000000 0000000 00000002776 14160146213 0016343 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.version` --- Software Version (XEP-0092) support
###############################################################
This subpackage provides support for querying and answering queries adhering to
the :xep:`92` (Software Support) protocol.
.. versionadded:: 0.10
The protocol allows entities to find out the software version and operating
system of other entities in the XMPP network.
.. currentmodule:: aioxmpp
.. autoclass:: aioxmpp.VersionServer()
.. currentmodule:: aioxmpp.version
.. autofunction:: query_version
.. automodule:: aioxmpp.version.xso
"""
from .service import ( # NOQA: F401
VersionServer,
query_version,
)
aioxmpp/version/service.py 0000664 0000000 0000000 00000014213 14160146213 0016231 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import typing
import aioxmpp
import aioxmpp.disco
import aioxmpp.errors
import aioxmpp.service
from . import xso as version_xso
class VersionServer(aioxmpp.service.Service):
"""
:class:`~aioxmpp.service.Service` which handles inbound :xep:`92` Software
Version requests.
.. warning::
Do **not** depend on this service in another service. This service
exposes possibly private or sensitive information over the XMPP
network without any filtering. Implicitly summoning this service via
a dependency is thus discouraged.
*If* you absolutely need to do this for the implementation of another
published XEP, please file an issue against :mod:`aioxmpp` so that we
can work out a good solution.
.. warning::
This service does answer version queries, no matter who asks. This may
not be desirable, in which case this service is not for you.
.. seealso::
:func:`~.version.query_version`
for a function to obtain another entities software version.
.. note::
By default, this service does not reply to version queries. The
:attr:`name` attribute needs to be set first.
The response can be configured with the following attributes:
.. autoattribute:: name
:annotation: = None
.. autoattribute:: version
:annotation: = None
.. autoattribute:: os
:annotation: = distro.name() or platform.system()
"""
ORDER_AFTER = [
aioxmpp.disco.DiscoServer,
]
disco_feature = aioxmpp.disco.register_feature(
"jabber:iq:version",
)
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
try:
import distro
except ImportError:
import platform
self._os = platform.system()
else:
self._os = distro.name()
self._name = None
self._version = None
@property
def os(self) -> typing.Optional[str]:
"""
The operating system of this entity.
Defaults to :func:`distro.name` or :func:`platform.system` (if
:mod:`distro` is not available).
This attribute can be set to :data:`None` or deleted to prevent
inclusion of the OS element in the reply.
"""
return self._os
@os.setter
def os(self, value: typing.Optional[str]):
if value is None:
self._os = None
else:
self._os = str(value)
@os.deleter
def os(self):
self._os = None
@property
def name(self) -> typing.Optional[str]:
"""
The software name of this entity.
Defaults to :data:`None`.
If this attribute is :data:`None`, version requests are not answered
but fail with a ``service-unavailable`` error.
"""
return self._name
@name.setter
def name(self, value: typing.Optional[str]):
if value is None:
self._name = None
else:
self._name = str(value)
@name.deleter
def name(self):
self._name = None
@property
def version(self) -> typing.Optional[str]:
"""
The software version of this entity.
Defaults to :data:`None`.
If this attribute is :data:`None` or the empty string, the version will
be shown as ``"unspecified"`` to other entities. This can be used to
avoid disclosing the specific version of the software.
"""
return self._version
@version.setter
def version(self, value: typing.Optional[str]):
if value is None:
self._version = None
else:
self._version = str(value)
@version.deleter
def version(self):
self._version = None
@aioxmpp.service.iq_handler(aioxmpp.IQType.GET,
version_xso.Query)
async def handle_query(self, iq: aioxmpp.IQ) -> version_xso.Query:
if self._name is None:
raise aioxmpp.errors.XMPPCancelError(
aioxmpp.errors.ErrorCondition.SERVICE_UNAVAILABLE,
)
result = version_xso.Query()
result.name = self._name
result.os = self._os
result.version = self._version or "unspecified"
return result
async def query_version(stream: aioxmpp.stream.StanzaStream,
target: aioxmpp.JID) -> version_xso.Query:
"""
Query the software version of an entity.
:param stream: A stanza stream to send the query on.
:type stream: :class:`aioxmpp.stream.StanzaStream`
:param target: The address of the entity to query.
:type target: :class:`aioxmpp.JID`
:raises OSError: if a connection issue occurred before a reply was received
:raises aioxmpp.errors.XMPPError: if an XMPP error was returned instead
of a reply.
:rtype: :class:`aioxmpp.version.xso.Query`
:return: The response from the peer.
The response is returned as :class:`~aioxmpp.version.xso.Query` object. The
attributes hold the data returned by the peer. Each attribute may be
:data:`None` if the peer chose to omit that information. In an extreme
case, all attributes are :data:`None`.
"""
return await stream.send(aioxmpp.IQ(
type_=aioxmpp.IQType.GET,
to=target,
payload=version_xso.Query(),
))
aioxmpp/version/xso.py 0000664 0000000 0000000 00000003403 14160146213 0015401 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
XSO Definitions
===============
.. autoclass:: Query
"""
import aioxmpp
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0092_version = "jabber:iq:version"
@aioxmpp.IQ.as_payload_class
class Query(xso.XSO):
"""
:xep:`92` Software Version query :class:`~aioxmpp.xso.XSO`.
.. attribute:: name
Software name as string. May be :data:`None`.
.. attribute:: version
Software version as string. May be :data:`None`.
.. attribute:: os
Operating system as string. May be :data:`None`.
"""
TAG = namespaces.xep0092_version, "query"
version = xso.ChildText(
(namespaces.xep0092_version, "version"),
default=None,
)
name = xso.ChildText(
(namespaces.xep0092_version, "name"),
default=None,
)
os = xso.ChildText(
(namespaces.xep0092_version, "os"),
default=None,
)
aioxmpp/xml.py 0000664 0000000 0000000 00000115735 14160146213 0013717 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: xml.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
# .
#
########################################################################
"""
:mod:`~aioxmpp.xml` --- XML utilities and interfaces for handling XMPP XML streams
#######################################################################################
This module provides a few classes and functions which are useful when
generating and parsing XML streams for XMPP.
Generating XML streams
======================
The most useful class here is the :class:`XMPPXMLGenerator`:
.. autoclass:: XMPPXMLGenerator
.. autoclass:: XMLStreamWriter
Processing XML streams
======================
To convert streams of SAX events to :class:`~.stanza_model.XSO`
instances, the following classes and functions can be used:
.. autoclass:: XMPPXMLProcessor
.. autoclass:: XMPPLexicalHandler
.. autofunction:: make_parser
Utility functions
=================
.. autofunction:: serialize_single_xso
.. autofunction:: write_single_xso
.. autofunction:: read_xso
.. autofunction:: read_single_xso
""" # NOQA: E501
import copy
import contextlib
import io
import xml.sax
import xml.sax.saxutils
from enum import Enum
from . import errors, structs, xso
from .utils import namespaces
_NAME_START_CHAR = [
[ord(":"), ord("_")],
range(ord("a"), ord("z")+1),
range(ord("A"), ord("Z")+1),
range(0xc0, 0xd7),
range(0xd8, 0xf7),
range(0xf8, 0x300),
range(0x370, 0x37e),
range(0x37f, 0x2000),
range(0x200c, 0x200e),
range(0x2070, 0x2190),
range(0x2c00, 0x2ff0),
range(0x3001, 0xd800),
range(0xf900, 0xfdd0),
range(0xfdf0, 0xfffe),
range(0x10000, 0xf0000),
]
_NAME_CHAR = _NAME_START_CHAR + [
[ord("-"), ord("."), 0xb7],
range(ord("0"), ord("9")+1),
range(0x0300, 0x0370),
range(0x203f, 0x2041),
]
_NAME_CHAR.sort(key=lambda x: x[0])
def xmlValidateNameValue_str(s):
if not s:
return False
ch = ord(s[0])
if not any(ch in range_ for range_ in _NAME_START_CHAR):
return False
return all(
any(ch in range_ for range_ in _NAME_CHAR)
for ch in map(ord, s)
)
def is_valid_cdata_str(s):
for c in s:
o = ord(c)
if o >= 32:
continue
if o < 9 or 11 <= o <= 12 or 14 <= o <= 31:
return False
return True
class XMPPXMLGenerator:
"""
Class to generate XMPP-conforming XML bytes.
:param out: File-like object to which the bytes are written.
:param short_empty_elements: Write empty elements as ```` instead of
````.
:type short_empty_elements: :class:`bool`
:param sorted_attributes: Sort the attributes in the output. Note: this
comes with a performance penalty. See below.
:type sorted_attributes: :class:`bool`
:param additional_escapes: Sequence of characters to escape in CDATA.
:type additional_escapes: :class:`~collections.abc.Iterable` of
1-codepoint :class:`str` objects.
:class:`XMPPXMLGenerator` works similar to
:class:`xml.sax.saxutils.XMLGenerator`, but has a few key differences:
* It supports **only** namespace-conforming XML documents
* It automatically chooses namespace prefixes if a namespace has not been
declared, while avoiding to use prefixes at all if possible
* It is in general stricter on (explicit) namespace declarations, to avoid
ambiguities
* It always uses utf-8 ☺
* It allows explicit flushing
`out` must be a file-like supporting both :meth:`file.write` and
:meth:`file.flush`.
If `short_empty_elements` is true, empty elements are rendered as
```` instead of ````, unless a flush occurs before the
call to :meth:`endElementNS`, in which case the opening is finished before
flushing, thus the long form is generated.
If `sorted_attributes` is true, attributes are emitted in the lexical order
of their qualified names (except for namespace declarations, which are
always sorted and always before the normal attributes). The default is not
to do this, for performance. During testing, however, it is useful to have
a consistent oder on the attributes.
All characters in `additional_escapes` are escaped using XML entities. Note
that ``<``, ``>`` and ``&`` are always escaped. `additional_escapes` is
converted to a dictionary for use with :func:`~xml.sax.saxutils.escape` and
:func:`~xml.sax.saxutils.quoteattr`. Passing a dictionary to
`additional_escapes` or passing multi-character strings as elements of
`additional_escapes` is **not** supported since it may be (ab-)used to
create invalid XMPP XML. `additional_escapes` affects both CDATA in XML
elements as well as attribute values.
Implementation of the SAX content handler interface (see
:class:`xml.sax.handler.ContentHandler`):
.. automethod:: startDocument
.. automethod:: startPrefixMapping(prefix, uri)
.. automethod:: startElementNS
.. automethod:: characters
.. automethod:: endElementNS
.. automethod:: endPrefixMapping
.. automethod:: endDocument
The following SAX content handler methods have deliberately not been
implemented:
.. automethod:: setDocumentLocator
.. automethod:: skippedEntity
.. automethod:: ignorableWhitespace
.. automethod:: startElement
.. automethod:: endElement
These methods produce content which is invalid in XMPP XML streams and thus
always raise :class:`ValueError`:
.. automethod:: processingInstruction
In addition to the SAX content handler interface, the following methods are
provided:
.. automethod:: flush
.. automethod:: buffer
"""
def __init__(self, out,
short_empty_elements=True,
sorted_attributes=False,
additional_escapes=[]):
self._write = out.write
if hasattr(out, "flush"):
self._flush = out.flush
else:
self._flush = None
self._short_empty_elements = short_empty_elements
self._sorted_attributes = sorted_attributes
self._additional_escapes = {
char: "{};".format(ord(char))
for char in additional_escapes
}
# NOTE: when adding state, make sure to handle it in buffer() and to
# add tests that buffer() handles it correctly
self._ns_map_stack = [({}, set(), 0)]
self._curr_ns_map = {}
self._pending_start_element = False
self._ns_prefixes_floating_in = {}
self._ns_prefixes_floating_out = set()
self._ns_auto_prefixes_floating_in = set()
self._ns_decls_floating_in = {}
self._ns_counter = -1
# for buffer()
self._buf = None
self._buf_in_use = False
def _roll_prefix(self, attr):
if not attr and None not in self._ns_prefixes_floating_in:
return None
prefix_number = self._ns_counter + 1
while True:
prefix = "ns{}".format(prefix_number)
if prefix not in self._ns_prefixes_floating_in:
break
prefix_number += 1
self._ns_counter = prefix_number
return prefix
def _qname(self, name, attr=False):
if not isinstance(name, tuple):
raise ValueError("names must be tuples")
if ":" in name[1] or not xmlValidateNameValue_str(name[1]):
raise ValueError("invalid name: {!r}".format(name[1]))
if name[0]:
if name[0] == "http://www.w3.org/XML/1998/namespace":
return "xml:" + name[1]
try:
prefix = self._ns_decls_floating_in[name[0]]
if attr and prefix is None:
raise KeyError()
except KeyError:
try:
prefix = self._curr_ns_map[name[0]]
if prefix in self._ns_prefixes_floating_in:
raise KeyError()
if attr and prefix is None:
raise KeyError()
except KeyError:
# namespace is undeclared, we have to declare it..
prefix = self._roll_prefix(attr)
self.startPrefixMapping(prefix, name[0], auto=True)
if prefix:
return ":".join((prefix, name[1]))
elif (not attr and
(None in self._curr_ns_map or
None in self._ns_prefixes_floating_in)):
raise ValueError("cannot create unnamespaced element when "
"prefixless namespace is bound")
return name[1]
def _finish_pending_start_element(self):
if not self._pending_start_element:
return
self._pending_start_element = False
self._write(b">")
def _pin_floating_ns_decls(self, old_counter):
if self._ns_prefixes_floating_out:
raise RuntimeError("namespace prefix has not been closed")
new_decls = self._ns_decls_floating_in
new_prefixes = self._ns_prefixes_floating_in
old_ns_map = self._curr_ns_map
self._ns_map_stack.append(
(
old_ns_map,
set(new_prefixes) - self._ns_auto_prefixes_floating_in,
old_counter
)
)
new_ns_map = dict(new_decls)
cleared_new_prefixes = dict(new_prefixes)
for uri, prefix in old_ns_map.items():
try:
new_uri = new_prefixes[prefix]
except KeyError:
pass
else:
if new_uri != uri:
# -> the entry must be dropped because the prefix is
# re-assigned
continue
# use setdefault: new entries (as assigned in new_ns_map =
# dict(...)) need to win over old entries
new_ns_map.setdefault(uri, prefix)
try:
new_uri = cleared_new_prefixes[prefix]
except KeyError:
pass
else:
if new_uri == uri:
del cleared_new_prefixes[prefix]
self._curr_ns_map = new_ns_map
self._ns_decls_floating_in = {}
self._ns_prefixes_floating_in = {}
self._ns_auto_prefixes_floating_in.clear()
return cleared_new_prefixes
def startDocument(self):
"""
Start the document. This method *must* be called before any other
content handler method.
"""
# yes, I know the doctext is not enforced. It might become enforced in
# a later version though, when I find a compelling reason why it is
# needed.
self._write(b'')
def startPrefixMapping(self, prefix, uri, *, auto=False):
"""
Start a prefix mapping which maps the given `prefix` to the given
`uri`.
Note that prefix mappings are handled transactional. All announcements
of prefix mappings are collected until the next call to
:meth:`startElementNS`. At that point, the mappings are collected and
start to override the previously declared mappings until the
corresponding :meth:`endElementNS` call.
Also note that calling :meth:`startPrefixMapping` is not mandatory; you
can use any namespace you like at any time. If you use a namespace
whose URI has not been associated with a prefix yet, a free prefix will
automatically be chosen. To avoid unnecessary performance penalties,
do not use prefixes of the form ``"ns{:d}".format(n)``, for any
non-negative number of `n`.
It is however required to call :meth:`endPrefixMapping` after a
:meth:`endElementNS` call for all namespaces which have been announced
directly before the :meth:`startElementNS` call (except for those which
have been chosen automatically). Not doing so will result in a
:class:`RuntimeError` at the next :meth:`startElementNS` or
:meth:`endElementNS` call.
During a transaction, it is not allowed to declare the same prefix
multiple times.
"""
if (prefix is not None and
(prefix == "xml" or
prefix == "xmlns" or
not xmlValidateNameValue_str(prefix) or
":" in prefix)):
raise ValueError("not a valid prefix: {!r}".format(prefix))
if prefix in self._ns_prefixes_floating_in:
raise ValueError("prefix already declared for next element")
if auto:
self._ns_auto_prefixes_floating_in.add(prefix)
self._ns_prefixes_floating_in[prefix] = uri
self._ns_decls_floating_in[uri] = prefix
def startElementNS(self, name, qname, attributes=None):
"""
Start a sub-element. `name` must be a tuple of ``(namespace_uri,
localname)`` and `qname` is ignored. `attributes` must be a dictionary
mapping attribute tag tuples (``(namespace_uri, attribute_name)``) to
string values. To use unnamespaced attributes, `namespace_uri` can be
false (e.g. :data:`None` or the empty string).
To use unnamespaced elements, `namespace_uri` in `name` must be false
**and** no namespace without prefix must be currently active. If a
namespace without prefix is active and `namespace_uri` in `name` is
false, :class:`ValueError` is raised.
Attribute values are of course automatically escaped.
"""
self._finish_pending_start_element()
old_counter = self._ns_counter
qname = self._qname(name)
if attributes:
attrib = [
(self._qname(attrname, attr=True), value)
for attrname, value in attributes.items()
]
for attrqname, _ in attrib:
if attrqname == "xmlns":
raise ValueError("xmlns not allowed as attribute name")
else:
attrib = []
pending_prefixes = self._pin_floating_ns_decls(old_counter)
self._write(b"<")
self._write(qname.encode("utf-8"))
if None in pending_prefixes:
uri = pending_prefixes.pop(None)
self._write(b" xmlns=")
self._write(xml.sax.saxutils.quoteattr(uri).encode("utf-8"))
for prefix, uri in sorted(pending_prefixes.items()):
self._write(b" xmlns")
if prefix:
self._write(b":")
self._write(prefix.encode("utf-8"))
self._write(b"=")
self._write(
xml.sax.saxutils.quoteattr(uri).encode("utf-8")
)
if self._sorted_attributes:
attrib.sort()
for attrname, value in attrib:
self._write(b" ")
self._write(attrname.encode("utf-8"))
self._write(b"=")
self._write(
xml.sax.saxutils.quoteattr(
value,
self._additional_escapes,
).encode("utf-8")
)
if self._short_empty_elements:
self._pending_start_element = name
else:
self._write(b">")
def endElementNS(self, name, qname):
"""
End a previously started element. `name` must be a ``(namespace_uri,
localname)`` tuple and `qname` is ignored.
"""
if self._ns_prefixes_floating_out:
raise RuntimeError("namespace prefix has not been closed")
if self._pending_start_element == name:
self._pending_start_element = False
self._write(b"/>")
else:
self._write(b"")
self._write(self._qname(name).encode("utf-8"))
self._write(b">")
self._curr_ns_map, self._ns_prefixes_floating_out, self._ns_counter = \
self._ns_map_stack.pop()
def endPrefixMapping(self, prefix):
"""
End a prefix mapping declared with :meth:`startPrefixMapping`. See
there for more details.
"""
self._ns_prefixes_floating_out.remove(prefix)
def startElement(self, name, attributes=None):
"""
Not supported; only elements with proper namespacing are supported by
this generator.
"""
raise NotImplementedError("namespace-incorrect documents are "
"not supported")
def characters(self, chars):
"""
Put character data in the currently open element. Special characters
(such as ``<``, ``>`` and ``&``) are escaped.
If `chars` contains any ASCII control character, :class:`ValueError` is
raised.
"""
self._finish_pending_start_element()
if not is_valid_cdata_str(chars):
raise ValueError("control characters are not allowed in "
"well-formed XML")
self._write(xml.sax.saxutils.escape(
chars,
self._additional_escapes,
).encode("utf-8"))
def processingInstruction(self, target, data):
"""
Not supported; explicitly forbidden in XMPP. Raises
:class:`ValueError`.
"""
raise ValueError("restricted xml: processing instruction forbidden")
def skippedEntity(self, name):
"""
Not supported; there is no use case. Raises
:class:`NotImplementedError`.
"""
raise NotImplementedError("skippedEntity")
def setDocumentLocator(self, locator):
"""
Not supported; there is no use case. Raises
:class:`NotImplementedError`.
"""
raise NotImplementedError("setDocumentLocator")
def ignorableWhitespace(self, whitespace):
"""
Not supported; could be mapped to :meth:`characters`.
"""
raise NotImplementedError("ignorableWhitespace")
def endElement(self, name):
"""
Not supported; only elements with proper namespacing are supported by
this generator.
"""
self.startElement(name)
def endDocument(self):
"""
This must be called at the end of the document. Note that this does not
call :meth:`flush`.
"""
def flush(self):
"""
Call :meth:`flush` on the object passed to the `out` argument of the
constructor. In addition, any unfinished opening tags are finished,
which can lead to expansion of the generated XML code (see note on the
`short_empty_elements` argument at the class documentation).
"""
self._finish_pending_start_element()
if self._flush:
self._flush()
@contextlib.contextmanager
def _save_state(self):
"""
Helper context manager for :meth:`buffer` which saves the whole state.
This is broken out in a separate method for readability and tested
indirectly by testing :meth:`buffer`.
"""
ns_prefixes_floating_in = copy.copy(self._ns_prefixes_floating_in)
ns_prefixes_floating_out = copy.copy(self._ns_prefixes_floating_out)
ns_decls_floating_in = copy.copy(self._ns_decls_floating_in)
curr_ns_map = copy.copy(self._curr_ns_map)
ns_map_stack = copy.copy(self._ns_map_stack)
pending_start_element = self._pending_start_element
ns_counter = self._ns_counter
# XXX: I have been unable to find a test justifying copying this :/
# for completeness, I’m still doing it
ns_auto_prefixes_floating_in = \
copy.copy(self._ns_auto_prefixes_floating_in)
try:
yield
except: # NOQA: E722
self._ns_prefixes_floating_in = ns_prefixes_floating_in
self._ns_prefixes_floating_out = ns_prefixes_floating_out
self._ns_decls_floating_in = ns_decls_floating_in
self._pending_start_element = pending_start_element
self._curr_ns_map = curr_ns_map
self._ns_map_stack = ns_map_stack
self._ns_counter = ns_counter
self._ns_auto_prefixes_floating_in = ns_auto_prefixes_floating_in
raise
@contextlib.contextmanager
def buffer(self):
"""
Context manager to temporarily buffer the output.
:raise RuntimeError: If two :meth:`buffer` context managers are used
nestedly.
If the context manager is left without exception, the buffered output
is sent to the actual sink. Otherwise, it is discarded.
In addition to the output being buffered, buffer also captures the
entire state of the XML generator and restores it to the previous state
if the context manager is left with an exception.
This can be used to fail-safely attempt to serialise a subtree and
return to a well-defined state if serialisation fails.
:meth:`flush` is not called automatically.
If :meth:`flush` is called while a :meth:`buffer` context manager is
active, no actual flushing happens (but unfinished opening tags are
closed as usual, see the `short_empty_arguments` parameter).
"""
if self._buf_in_use:
raise RuntimeError("nested use of buffer() is not supported")
self._buf_in_use = True
old_write = self._write
old_flush = self._flush
if self._buf is None:
self._buf = io.BytesIO()
else:
try:
self._buf.seek(0)
self._buf.truncate()
except BufferError:
# we need a fresh buffer for this, the other is still in use.
self._buf = io.BytesIO()
self._write = self._buf.write
self._flush = None
try:
with self._save_state():
yield
old_write(self._buf.getbuffer())
if old_flush:
old_flush()
finally:
self._buf_in_use = False
self._write = old_write
self._flush = old_flush
class XMLStreamWriter:
"""
A convenient class to write a standard conforming XML stream.
:param f: File-like object to write to.
:param to: Address to which the connection is addressed.
:type to: :class:`aioxmpp.JID`
:param from_: Optional address from which the connection originates.
:type from_: :class:`aioxmpp.JID`
:param version: Version of the XML stream protocol.
:type version: :class:`tuple` of (:class:`int`, :class:`int`)
:param nsmap: Mapping of namespaces to declare at the stream header.
.. note::
The constructor *does not* send a stream header. :meth:`start` must be
called explicitly to send a stream header.
The generated stream header follows :rfc:`6120` and has the ``to`` and
``version`` attributes as well as optionally the ``from`` attribute
(controlled by `from_`). In addition, the namespace prefixes defined by
`nsmap` (mapping prefixes to namespace URIs) are declared on the stream
header.
.. note::
It is unfortunately not allowed to use namespace prefixes in stanzas
which were declared in stream headers as convenient as that would be.
The option is thus only useful to declare the default namespace for
stanzas.
.. autoattribute:: closed
The following methods are used to generate output:
.. automethod:: start
.. automethod:: send
.. automethod:: abort
.. automethod:: close
"""
def __init__(self, f, to,
from_=None,
version=(1, 0),
nsmap={},
sorted_attributes=False):
super().__init__()
self._to = to
self._from = from_
self._version = version
self._writer = XMPPXMLGenerator(
out=f,
short_empty_elements=True,
sorted_attributes=sorted_attributes)
self._nsmap_to_use = {
"stream": namespaces.xmlstream
}
self._nsmap_to_use.update(nsmap)
self._closed = False
@property
def closed(self):
"""
True if the stream has been closed by :meth:`abort` or :meth:`close`.
Read-only.
"""
return self._closed
def start(self):
"""
Send the stream header as described above.
"""
attrs = {
(None, "to"): str(self._to),
(None, "version"): ".".join(map(str, self._version))
}
if self._from:
attrs[None, "from"] = str(self._from)
self._writer.startDocument()
for prefix, uri in self._nsmap_to_use.items():
self._writer.startPrefixMapping(prefix, uri)
self._writer.startElementNS(
(namespaces.xmlstream, "stream"),
None,
attrs)
self._writer.flush()
def send(self, xso):
"""
Send a single XML stream object.
:param xso: Object to serialise and send.
:type xso: :class:`aioxmpp.xso.XSO`
:raises Exception: from any serialisation errors, usually
:class:`ValueError`.
Serialise the `xso` and send it over the stream. If any serialisation
error occurs, no data is sent over the stream and the exception is
re-raised; the :meth:`send` method thus provides strong exception
safety.
.. warning::
The behaviour of :meth:`send` after :meth:`abort` or :meth:`close`
and before :meth:`start` is undefined.
"""
with self._writer.buffer():
xso.xso_serialise_to_sax(self._writer)
def abort(self):
"""
Abort the stream.
The stream is flushed and the internal data structures are cleaned up.
No stream footer is sent. The stream is :attr:`closed` afterwards.
If the stream is already :attr:`closed`, this method does nothing.
"""
if self._closed:
return
self._closed = True
self._writer.flush()
del self._writer
def close(self):
"""
Close the stream.
The stream footer is sent and the internal structures are cleaned up.
If the stream is already :attr:`closed`, this method does nothing.
"""
if self._closed:
return
self._closed = True
self._writer.endElementNS((namespaces.xmlstream, "stream"), None)
for prefix in self._nsmap_to_use:
self._writer.endPrefixMapping(prefix)
self._writer.endDocument()
del self._writer
class ProcessorState(Enum):
CLEAN = 0
STARTED = 1
STREAM_HEADER_PROCESSED = 2
STREAM_FOOTER_PROCESSED = 3
EXCEPTION_BACKOFF = 4
class XMPPXMLProcessor:
"""
This class is a :class:`xml.sax.handler.ContentHandler`. It
can be used to parse an XMPP XML stream.
When used with a :class:`xml.sax.xmlreader.XMLReader`, it gradually
processes the incoming XML stream. If any restricted XML is encountered, an
appropriate :class:`~.errors.StreamError` is raised.
.. warning::
To achieve compliance with XMPP, it is recommended to use
:class:`XMPPLexicalHandler` as lexical handler, using
:meth:`xml.sax.xmlreader.XMLReader.setProperty`::
parser.setProperty(xml.sax.handler.property_lexical_handler,
XMPPLexicalHandler)
Otherwise, invalid XMPP XML such as comments, entity references and DTD
declarations will not be caught.
**Exception handling**: When an exception occurs while parsing a
stream-level element, such as a stanza, the exception is stored internally
and exception handling is invoked. During exception handling, all SAX
events are dropped, until the stream-level element has been completely
processed by the parser. Then, if available, :attr:`on_exception` is
called, with the stored exception as the only argument. If
:attr:`on_exception` is false (e.g. :data:`None`), the exception is
re-raised from the :meth:`endElementNS` handler, in turn most likely
destroying the SAX parsers internal state.
.. attribute:: on_exception
May be a callable or :data:`None`. If not false, the value will get
called when exception handling has finished, with the exception as the
only argument.
.. attribute:: on_stream_footer
May be a callable or :data:`None`. If not false, the value will get
called whenever a stream footer is processed.
.. attribute:: on_stream_header
May be a callable or :data:`None`. If not false, the value will get
called whenever a stream header is processed.
.. autoattribute:: stanza_parser
"""
def __init__(self):
super().__init__()
self._state = ProcessorState.CLEAN
self._stanza_parser = None
self._stored_exception = None
self.on_stream_header = None
self.on_stream_footer = None
self.on_exception = None
self.remote_version = None
self.remote_from = None
self.remote_to = None
self.remote_id = None
self.remote_lang = None
@property
def stanza_parser(self):
"""
A :class:`~.xso.XSOParser` object (or compatible) which will
receive the sax-ish events used in :mod:`~aioxmpp.xso`. It
is driven using an instance of :class:`~.xso.SAXDriver`.
This object can only be set before :meth:`startDocument` has been
called (or after :meth:`endDocument` has been called).
"""
return self._stanza_parser
@stanza_parser.setter
def stanza_parser(self, value):
if self._state != ProcessorState.CLEAN:
raise RuntimeError("invalid state: {}".format(self._state))
self._stanza_parser = value
self._stanza_parser.lang = self.remote_lang
def processingInstruction(self, target, foo):
raise errors.StreamError(
errors.StreamErrorCondition.RESTRICTED_XML,
"processing instructions are not allowed in XMPP"
)
def characters(self, characters):
if self._state == ProcessorState.EXCEPTION_BACKOFF:
pass
elif self._state != ProcessorState.STREAM_HEADER_PROCESSED:
raise RuntimeError("invalid state: {}".format(self._state))
else:
self._driver.characters(characters)
def startDocument(self):
if self._state != ProcessorState.CLEAN:
raise RuntimeError("invalid state: {}".format(self._state))
self._state = ProcessorState.STARTED
self._depth = 0
self._driver = xso.SAXDriver(self._stanza_parser)
def startElement(self, name, attributes):
raise RuntimeError("incorrectly configured parser: "
"startElement called (instead of startElementNS)")
def endElement(self, name):
raise RuntimeError("incorrectly configured parser: "
"endElement called (instead of endElementNS)")
def endDocument(self):
if self._state != ProcessorState.STREAM_FOOTER_PROCESSED:
raise RuntimeError("invalid state: {}".format(self._state))
self._state = ProcessorState.CLEAN
self._driver = None
def startPrefixMapping(self, prefix, uri):
pass
def endPrefixMapping(self, prefix):
pass
def startElementNS(self, name, qname, attributes):
if self._state == ProcessorState.STREAM_HEADER_PROCESSED:
try:
self._driver.startElementNS(name, qname, attributes)
except Exception as exc:
self._stored_exception = exc
self._state = ProcessorState.EXCEPTION_BACKOFF
self._depth += 1
return
elif self._state == ProcessorState.EXCEPTION_BACKOFF:
self._depth += 1
return
elif self._state != ProcessorState.STARTED:
raise RuntimeError("invalid state: {}".format(self._state))
if name != (namespaces.xmlstream, "stream"):
raise errors.StreamError(
errors.StreamErrorCondition.INVALID_NAMESPACE,
"stream has invalid namespace or localname"
)
attributes = dict(attributes)
try:
self.remote_version = tuple(
map(int, attributes.pop((None, "version"), "0.9").split("."))
)
except ValueError as exc:
raise errors.StreamError(
errors.StreamErrorCondition.UNSUPPORTED_VERSION,
str(exc)
)
remote_to = attributes.pop((None, "to"), None)
if remote_to is not None:
remote_to = structs.JID.fromstr(remote_to)
self.remote_to = remote_to
try:
self.remote_from = structs.JID.fromstr(
attributes.pop((None, "from"))
)
except KeyError:
raise errors.StreamError(
errors.StreamErrorCondition.UNDEFINED_CONDITION,
"from attribute required in response header"
)
try:
self.remote_id = attributes.pop((None, "id"))
except KeyError:
raise errors.StreamError(
errors.StreamErrorCondition.UNDEFINED_CONDITION,
"id attribute required in response header"
)
try:
lang = attributes.pop((namespaces.xml, "lang"))
except KeyError:
self.remote_lang = None
else:
self.remote_lang = structs.LanguageTag.fromstr(lang)
if self._stanza_parser is not None:
self._stanza_parser.lang = self.remote_lang
if self.on_stream_header:
self.on_stream_header()
self._state = ProcessorState.STREAM_HEADER_PROCESSED
self._depth += 1
def _end_element_exception_handling(self):
self._state = ProcessorState.STREAM_HEADER_PROCESSED
exc = self._stored_exception
self._stored_exception = None
if self.on_exception:
self.on_exception(exc)
else:
raise exc
def endElementNS(self, name, qname):
if self._state == ProcessorState.STREAM_HEADER_PROCESSED:
self._depth -= 1
if self._depth > 0:
try:
return self._driver.endElementNS(name, qname)
except Exception as exc:
self._stored_exception = exc
self._state = ProcessorState.EXCEPTION_BACKOFF
if self._depth == 1:
self._end_element_exception_handling()
else:
if self.on_stream_footer:
self.on_stream_footer()
self._state = ProcessorState.STREAM_FOOTER_PROCESSED
elif self._state == ProcessorState.EXCEPTION_BACKOFF:
self._depth -= 1
if self._depth == 1:
self._end_element_exception_handling()
else:
raise RuntimeError("invalid state: {}".format(self._state))
class XMPPLexicalHandler:
"""
A `lexical handler
`_
which rejects certain contents which are invalid in an XMPP XML stream:
* comments,
* dtd declarations,
* non-predefined entities.
The class can be used as lexical handler directly; all methods are
stateless and can be used both on the class and on objects of the class.
"""
PREDEFINED_ENTITIES = {"amp", "lt", "gt", "apos", "quot"}
@classmethod
def comment(cls, data):
raise errors.StreamError(
errors.StreamErrorCondition.RESTRICTED_XML,
"comments are not allowed in XMPP"
)
@classmethod
def startDTD(cls, name, publicId, systemId):
raise errors.StreamError(
errors.StreamErrorCondition.RESTRICTED_XML,
"DTD declarations are not allowed in XMPP"
)
@classmethod
def endDTD(cls):
pass
@classmethod
def startCDATA(cls):
pass
@classmethod
def endCDATA(cls):
pass
@classmethod
def startEntity(cls, name):
if name not in cls.PREDEFINED_ENTITIES:
raise errors.StreamError(
errors.StreamErrorCondition.RESTRICTED_XML,
"non-predefined entities are not allowed in XMPP"
)
@classmethod
def endEntity(cls, name):
pass
def make_parser():
"""
Create a parser which is suitably configured for parsing an XMPP XML
stream. It comes equipped with :class:`XMPPLexicalHandler`.
"""
p = xml.sax.make_parser()
p.setFeature(xml.sax.handler.feature_namespaces, True)
p.setFeature(xml.sax.handler.feature_external_ges, False)
p.setProperty(xml.sax.handler.property_lexical_handler,
XMPPLexicalHandler)
return p
def serialize_single_xso(x):
"""
Serialize a single XSO `x` to a string. This is potentially very slow and
should only be used for debugging purposes. It is generally more efficient
to use a :class:`XMPPXMLGenerator` to stream elements.
"""
buf = io.BytesIO()
gen = XMPPXMLGenerator(buf,
short_empty_elements=True,
sorted_attributes=True)
x.xso_serialise_to_sax(gen)
return buf.getvalue().decode("utf8")
def write_single_xso(x, dest):
"""
Write a single XSO `x` to a binary file-like object `dest`.
"""
gen = XMPPXMLGenerator(dest,
short_empty_elements=True,
sorted_attributes=True)
x.xso_serialise_to_sax(gen)
def read_xso(src, xsomap):
"""
Read a single XSO from a binary file-like input `src` containing an XML
document.
`xsomap` must be a mapping which maps :class:`~.XSO` subclasses
to callables. These will be registered at a newly created
:class:`.xso.XSOParser` instance which will be used to parse the document
in `src`.
The `xsomap` is thus used to determine the class parsing the root element
of the XML document. This can be used to support multiple versions.
"""
xso_parser = xso.XSOParser()
for class_, cb in xsomap.items():
xso_parser.add_class(class_, cb)
driver = xso.SAXDriver(xso_parser)
parser = xml.sax.make_parser()
parser.setFeature(
xml.sax.handler.feature_namespaces,
True)
parser.setFeature(
xml.sax.handler.feature_external_ges,
False)
parser.setContentHandler(driver)
parser.parse(src)
def read_single_xso(src, type_):
"""
Read a single :class:`~.XSO` of the given `type_` from the binary file-like
input `src` and return the instance.
"""
result = None
def cb(instance):
nonlocal result
result = instance
read_xso(src, {type_: cb})
return result
aioxmpp/xmltestutils.py 0000664 0000000 0000000 00000014312 14160146213 0015665 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: xmltestutils.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
# .
#
########################################################################
import unittest
def element_path(el, upto=None):
segments = []
parent = el.getparent()
while parent != upto:
similar = list(parent.iterchildren(el.tag))
index = similar.index(el)
segments.insert(0, (el.tag, index))
el = parent
parent = el.getparent()
base = "/" + el.tag
if segments:
return base + "/" + "/".join(
"{}[{}]".format(tag, index)
for tag, index in segments
)
return base
class XMLTestCase(unittest.TestCase):
def assertChildrenEqual(self, tree1, tree2,
strict_ordering=False,
ignore_surplus_children=False,
ignore_surplus_attr=False):
if not strict_ordering:
t1_childmap = {}
for child in tree1:
t1_childmap.setdefault(child.tag, []).append(child)
t2_childmap = {}
for child in tree2:
t2_childmap.setdefault(child.tag, []).append(child)
t1_childtags = frozenset(t1_childmap)
t2_childtags = frozenset(t2_childmap)
if not ignore_surplus_children or (t1_childtags - t2_childtags):
self.assertSetEqual(
t1_childtags,
t2_childtags,
"Child tag occurrence differences at {}".format(
element_path(tree2)))
for tag, t1_children in t1_childmap.items():
t2_children = t2_childmap.get(tag, [])
self.assertLessEqual(
len(t1_children),
len(t2_children),
"Surplus child at {}".format(element_path(tree2))
)
if not ignore_surplus_children:
self.assertGreaterEqual(
len(t1_children),
len(t2_children),
"Surplus child at {}".format(element_path(tree2))
)
for c1, c2 in zip(t1_children, t2_children):
self.assertSubtreeEqual(
c1, c2,
ignore_surplus_attr=ignore_surplus_attr,
ignore_surplus_children=ignore_surplus_children,
strict_ordering=strict_ordering)
else:
t1_children = list(tree1)
t2_children = list(tree2)
self.assertLessEqual(
len(t1_children),
len(t2_children),
"Surplus child at {}".format(element_path(tree2))
)
if not ignore_surplus_children:
self.assertGreaterEqual(
len(t1_children),
len(t2_children),
"Surplus child at {}".format(element_path(tree2))
)
for c1, c2 in zip(t1_children, t2_children):
self.assertSubtreeEqual(
c1, c2,
ignore_surplus_attr=ignore_surplus_attr,
ignore_surplus_children=ignore_surplus_children,
strict_ordering=strict_ordering)
def assertAttributesEqual(self, el1, el2,
ignore_surplus_attr=False):
t1_attrdict = el1.attrib
t2_attrdict = el2.attrib
t1_attrs = set(t1_attrdict)
t2_attrs = set(t2_attrdict)
if not ignore_surplus_attr or (t1_attrs - t2_attrs):
self.assertSetEqual(
t1_attrs,
t2_attrs,
"Attribute key differences at {}".format(element_path(el2))
)
for attr in t1_attrs:
self.assertEqual(
t1_attrdict[attr],
t2_attrdict[attr],
"Attribute value difference at {}@{}".format(
element_path(el2),
attr))
def _collect_text_parts(self, el):
parts = [el.text or ""]
parts.extend(child.tail or "" for child in el)
return parts
def assertTextContentEqual(self, el1, el2, join_text_parts=True):
parts1 = self._collect_text_parts(el1)
parts2 = self._collect_text_parts(el2)
if join_text_parts:
self.assertEqual(
"".join(parts1),
"".join(parts2),
"text mismatch at {}".format(element_path(el2))
)
else:
self.assertSequenceEqual(
parts1,
parts2,
"text mismatch at {}".format(element_path(el2))
)
def assertSubtreeEqual(self, tree1, tree2,
ignore_surplus_attr=False,
ignore_surplus_children=False,
strict_ordering=False,
join_text_parts=True):
self.assertEqual(tree1.tag, tree2.tag,
"tag mismatch at {}".format(element_path(tree2)))
self.assertTextContentEqual(tree1, tree2,
join_text_parts=join_text_parts)
self.assertAttributesEqual(tree1, tree2,
ignore_surplus_attr=ignore_surplus_attr)
self.assertChildrenEqual(
tree1, tree2,
ignore_surplus_children=ignore_surplus_children,
ignore_surplus_attr=ignore_surplus_attr,
strict_ordering=strict_ordering)
aioxmpp/xso/ 0000775 0000000 0000000 00000000000 14160146213 0013342 5 ustar 00root root 0000000 0000000 aioxmpp/xso/__init__.py 0000664 0000000 0000000 00000043502 14160146213 0015457 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
"""
:mod:`~aioxmpp.xso` --- Working with XML stream contents
########################################################
This subpackage deals with **X**\\ ML **S**\\ tream **O**\\ bjects. XSOs can be
stanzas, but in general any XML.
The facilities in this subpackage are supposed to help developers of XEP
plugins, as well as the main development of :mod:`aioxmpp`. The subpackage
is split in two parts, :mod:`aioxmpp.xso.model`, which provides facilities to
allow declarative-style parsing and un-parsing of XML subtrees into XSOs and
the :mod:`aioxmpp.xso.types` module, which provides classes which implement
validators and type parsers for content represented as strings in XML.
Introduction
============
.. seealso::
For a more in-depth introduction into :mod:`aioxmpp.xso`, please refer to
the :ref:`ug-introduction-to-xso` chapter in the user guide. This document
here is a reference manual.
The :mod:`aioxmpp.xso` subpackage provides declarative-style parsing of XML
document fragments. The declarations are similar to what you might know from
declarative Object-Relational-Mappers such as :mod:`sqlalchemy`. Due to the
different data model of XML and relational databases, they are not identical
of course.
An abstract class describing the common properties of an XMPP stanza might
look like this:
.. code:: python
class Stanza(xso.XSO):
from_ = xso.Attr(tag="from", type_=xso.JID(), default=None)
to = xso.Attr(tag="to", type_=xso.JID(), default=None)
lang = xso.LangAttr(tag=(namespaces.xml, "lang"))
Instances of classes deriving from :class:`aioxmpp.xso.XSO` are called XML
stream objects, or XSOs for short. Each XSO maps to an XML element node.
The declaration of an XSO class typically has one or more
:term:`descriptors ` describing the mapping of XML child nodes of
the element. XML nodes which can be mapped include attributes, text and
elements (processing instructions and comments are not supported; CDATA
sections are treated like text).
XSO-specific Terminology
========================
Definition of an XSO
--------------------
An XSO is an object whose class inherits from
:class:`aioxmpp.xso.XSO`.
A word on tags
--------------
Tags, as used by etree, are used throughout this module. Note that we are
representing tags as tuples of ``(namespace_uri, localname)``, where
``namespace_uri`` may be :data:`None`.
.. seealso::
The functions :func:`normalize_tag` and :func:`tag_to_str` are useful to
convert from and to ElementTree compatible strings.
XML stream events
-----------------
XSOs are parsed using SAX-like events. This allows them to be built one-by-one
in memory (and discarded) even while the XML stream is in progress.
The XSO module uses a subset of the original SAX event list, and it uses a
custom format. The reason for that is that instead of using an interface with
methods, the parsing parts are implemented using suspendable functions (see
below).
Suspendable functions
---------------------
This module uses suspendable functions, implemented as generators, at several
points. These may also be called coroutines, but have nothing to do with
coroutines as used by :mod:`asyncio`, which is why we will call them
suspendable functions here.
Suspendable functions possibly take arguments and then operate on input which
is fed to them in a push-manner step by step (using the
:meth:`~types.GeneratorType.send` method). The main usage in this module is to
process XML stream events: The SAX events are processed step-by-step by the functions,
and when the event is fully processed, it suspends itself (using ``yield``)
until the next event is sent into it.
General functions
=================
.. autofunction:: normalize_tag
.. autofunction:: tag_to_str
.. module:: aioxmpp.xso.model
.. currentmodule:: aioxmpp.xso
Object declaration with :mod:`aioxmpp.xso.model`
================================================
This module provides facilities to create classes which map to full XML stream
subtrees (for example stanzas including payload).
To create such a class, derive from :class:`XSO` and provide attributes
using the :class:`Attr`, :class:`Text`, :class:`Child` and :class:`ChildList`
descriptors.
Descriptors for XML-sourced attributes
--------------------------------------
.. autosummary::
Attr
LangAttr
Text
Child
ChildTag
ChildFlag
ChildText
ChildTextMap
ChildValue
ChildList
ChildMap
ChildLangMap
ChildValueList
ChildValueMap
ChildValueMultiMap
Collector
The following descriptors can be used to load XSO attributes from XML. There
are two fundamentally different descriptor types: *scalar* and *non-scalar*
(e.g. list) descriptors. Assignment to the descriptor attribute is
strictly type-checked for *scalar* descriptors.
Scalar descriptors
^^^^^^^^^^^^^^^^^^
Many of the arguments and attributes used for the scalar descriptors are
similar. They are described in detail on the :class:`Attr` class and not
repeated that detailed on the other classes. Refer to the documentation of the
:class:`Attr` class in those cases.
.. autoclass:: Attr(name, *[, type_=xso.String()][, validator=None][, validate=ValidateMode.FROM_RECV][, missing=None][, default][, erroneous_as_absent=False])
.. autoclass:: LangAttr(*[, validator=None][, validate=ValidateMode.FROM_RECV][, default=None])
.. autoclass:: Child(classes, *[, required=False][, strict=False])
.. autoclass:: ChildTag(tags, *[, text_policy=UnknownTextPolicy.FAIL][, child_policy=UnknownChildPolicy.FAIL][, attr_policy=UnknownAttrPolicy.FAIL][, default_ns=None][, allow_none=False])
.. autoclass:: ChildFlag(tag, *[, text_policy=UnknownTextPolicy.FAIL][, child_policy=UnknownChildPolicy.FAIL][, attr_policy=UnknownAttrPolicy.FAIL])
.. autoclass:: ChildText(tag, *[, child_policy=UnknownChildPolicy.FAIL][, attr_policy=UnknownAttrPolicy.FAIL][, type_=xso.String()][, validator=None][, validate=ValidateMode.FROM_RECV][, default][, erroneous_as_absent=False])
.. autoclass:: ChildValue(type_)
.. autoclass:: Text(*[, type_=xso.String()][, validator=None][, validate=ValidateMode.FROM_RECV][, default][, erroneous_as_absent=False])
Non-scalar descriptors
^^^^^^^^^^^^^^^^^^^^^^
.. autoclass:: ChildList(classes)
.. autoclass:: ChildMap(classes[, key=None])
.. autoclass:: ChildLangMap(classes)
.. autoclass:: ChildValueList(type_)
.. autoclass:: ChildValueMap(type_, *, mapping_type=dict)
.. autoclass:: ChildValueMultiMap(type_, *, mapping_type=multidict.MultiDict)
.. autoclass:: ChildTextMap(xso_type)
.. autoclass:: Collector()
Container for child lists
^^^^^^^^^^^^^^^^^^^^^^^^^
The child lists in :class:`ChildList`, :class:`ChildMap` and
:class:`ChildLangMap` descriptors use a specialized list-subclass which
provides advanced capabilities for filtering :class:`XSO` objects.
.. currentmodule:: aioxmpp.xso.model
.. autoclass:: XSOList
.. currentmodule:: aioxmpp.xso
Parsing XSOs
------------
To parse XSOs, an asynchronous approach which uses SAX-like events is
followed. For this, the suspendable functions explained earlier are used. The
main class to parse a XSO from events is :class:`XSOParser`. To drive
that suspendable callable from SAX events, use a :class:`SAXDriver`.
.. autoclass:: XSOParser
.. autoclass:: SAXDriver
Base and meta class
-------------------
The :class:`XSO` base class makes use of the :class:`model.XMLStreamClass`
metaclass and provides implementations for utility methods. For an object to
work with this module, it must derive from :class:`XSO` or provide an
identical interface.
.. autoclass:: XSO()
.. autoclass:: CapturingXSO()
The metaclass takes care of collecting the special descriptors in attributes
where they can be used by the SAX event interpreter to fill the class with
data. It also provides a class method for late registration of child classes.
.. currentmodule:: aioxmpp.xso.model
.. autoclass:: XMLStreamClass
.. currentmodule:: aioxmpp.xso
To create an enumeration of XSO classes, the following mixin can be used:
.. autoclass:: XSOEnumMixin
Functions, enumerations and exceptions
--------------------------------------
The values of the following enumerations are used on "magic" attributes of
:class:`XMLStreamClass` instances (i.e. classes).
.. autoclass:: UnknownChildPolicy
.. autoclass:: UnknownAttrPolicy
.. autoclass:: UnknownTextPolicy
.. autoclass:: ValidateMode
The following exceptions are generated at some places in this module:
.. autoclass:: UnknownTopLevelTag
The following special value is used to indicate that no default is used with a
descriptor:
.. data:: NO_DEFAULT
This is a special value which is used to indicate that no defaulting should
take place. It can be passed to the `default` arguments of descriptors, and
usually is the default value of these arguments.
It compares unequal to everything but itself, does not support ordering,
conversion to bool, float or integer.
.. autofunction:: capture_events
.. autofunction:: events_to_sax
Handlers for missing attributes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. autofunction:: lang_attr
.. module:: aioxmpp.xso.types
.. currentmodule:: aioxmpp.xso
Types and validators from :mod:`~aioxmpp.xso.types`
===================================================
This module provides classes whose objects can be used as types and validators
in :mod:`~aioxmpp.xso.model`.
Character Data types
--------------------
.. autosummary::
String
Float
Integer
Bool
DateTime
Date
Time
Base64Binary
HexBinary
JID
ConnectionLocation
LanguageTag
JSON
EnumCDataType
These types describe character data, i.e. text in XML. Thus, they can be used
with :class:`Attr`, :class:`Text` and similar descriptors. They are used to
deserialise XML character data to python values, such as integers or dates and
vice versa. These types inherit from :class:`AbstractCDataType`.
.. autoclass:: String
.. autoclass:: Float
.. autoclass:: Integer
.. autoclass:: Bool
.. autoclass:: DateTime
.. autoclass:: Date
.. autoclass:: Time
.. autoclass:: Base64Binary
.. autoclass:: HexBinary
.. autoclass:: JID
.. autoclass:: ConnectionLocation
.. autoclass:: LanguageTag
.. autoclass:: JSON
.. autoclass:: EnumCDataType(enum_class, nested_type=xso.String(), *, allow_coerce=False, deprecate_coerce=False, allow_unknown=True, accept_unknown=True)
.. autofunction:: EnumType(enum_class[, nested_type], *, allow_coerce=False, deprecate_coerce=False, allow_unknown=True, accept_unknown=True)
.. autoclass:: Unknown
Element types
-------------
.. autosummary::
EnumElementType
TextChildMap
These types describe structured XML data, i.e. subtrees. Thus, they can be used
with the :class:`ChildValueList` and :class:`ChildValueMap` family of
descriptors (which represent XSOs as python values). These types inherit from
:class:`AbstractElementType`.
.. autoclass:: EnumElementType
.. autoclass:: TextChildMap
Defining custom types
---------------------
.. autoclass:: AbstractCDataType
.. autoclass:: AbstractElementType
Validators
----------
Validators validate the python values after they have been parsed from
XML-sourced strings or even when being assigned to a descriptor attribute
(depending on the choice in the `validate` argument).
They can be useful both for defending and rejecting incorrect input and to
avoid producing incorrect output.
The basic validator interface
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. autoclass:: AbstractValidator
Implementations
^^^^^^^^^^^^^^^
.. autoclass:: RestrictToSet
.. autoclass:: Nmtoken
.. autoclass:: IsInstance
.. autoclass:: NumericRange
.. module:: aioxmpp.xso.query
.. currentmodule:: aioxmpp.xso
Querying data from XSOs
=======================
With XML, we have XPath as query language to retrieve data from XML trees. With
XSOs, we have :mod:`aioxmpp.xso.query`, even though it’s not as powerful as
XPath.
Syntactically, it’s oriented on XPath. Consider the following XSO classes:
.. code-block:: python
class FooXSO(xso.XSO):
TAG = (None, "foo")
attr = xso.Attr(
"attr"
)
class BarXSO(xso.XSO):
TAG = (None, "bar")
child = xso.Child([
FooXSO,
])
class BazXSO(FooXSO):
TAG = (None, "baz")
attr2 = xso.Attr(
"attr2"
)
class RootXSO(xso.XSO):
TAG = (None, "root")
children = xso.ChildList([
FooXSO,
BarXSO,
])
attr = xso.Attr(
"attr"
)
To perform a query, we first need to set up a
:class:`.query.EvaluationContext`:
.. code-block:: python
root_xso = # a RootXSO instance
ec = xso.query.EvaluationContext()
ec.set_toplevel_object(root_xso)
Using the context, we can now execute queries:
.. code-block:: python
# to find all FooXSO children of the RootXSO
ec.eval(RootXSO.children / FooXSO)
# to find all BarXSO children of the RootXSO
ec.eval(RootXSO.children / BarXSO)
# to find all FooXSO children of the RootXSO, where FooXSO.attr
# is set
ec.eval(RootXSO.children / FooXSO[where(FooXSO.attr)])
# to find all FooXSO children of the RootXSO, where FooXSO.attr
# is *not* set
ec.eval(RootXSO.children / FooXSO[where(not FooXSO.attr)])
# to find all FooXSO children of the RootXSO, where FooXSO.attr
# is set to "foobar"
ec.eval(RootXSO.children / FooXSO[where(FooXSO.attr == "foobar")])
# to test whether there is a FooXSO which has attr set to
# "foobar"
ec.eval(RootXSO.children / FooXSO.attr == "foobar")
# to find the first three FooXSO children where attr is set
ec.eval(RootXSO.children / FooXSO[where(FooXSO.attr)][:3])
The following operators are available in the :mod:`aioxmpp.xso` namespace:
.. autoclass:: where
.. autofunction:: not_
The following need to be explicitly sourced from :mod:`aioxmpp.xso.query`, as
they are rarely used directly in user code.
.. currentmodule:: aioxmpp.xso.query
.. autoclass:: EvaluationContext()
.. note::
The implementation details of the query language are documented in the
source. They are not useful unless you want to implement custom query
operators, which is not possible without modifying the
:mod:`aioxmpp.xso.query` source anyways.
.. currentmodule:: aioxmpp.xso
Predefined XSO base classes
===========================
Some patterns reoccur when using this subpackage. For these, base classes are
provided which facilitate the use.
.. autoclass:: AbstractTextChild
""" # NOQA: E501
def tag_to_str(tag):
"""
`tag` must be a tuple ``(namespace_uri, localname)``. Return a tag string
conforming to the ElementTree specification. Example::
tag_to_str(("jabber:client", "iq")) == "{jabber:client}iq"
"""
return "{{{:s}}}{:s}".format(*tag) if tag[0] else tag[1]
def normalize_tag(tag):
"""
Normalize an XML element tree `tag` into the tuple format. The following
input formats are accepted:
* ElementTree namespaced string, e.g. ``{uri:bar}foo``
* Unnamespaced tags, e.g. ``foo``
* Two-tuples consisting of `namespace_uri` and `localpart`; `namespace_uri`
may be :data:`None` if the tag is supposed to be namespaceless. Otherwise
it must be, like `localpart`, a :class:`str`.
Return a two-tuple consisting the ``(namespace_uri, localpart)`` format.
"""
if isinstance(tag, str):
namespace_uri, sep, localname = tag.partition("}")
if sep:
if not namespace_uri.startswith("{"):
raise ValueError("not a valid etree-format tag")
namespace_uri = namespace_uri[1:]
else:
localname = namespace_uri
namespace_uri = None
return (namespace_uri, localname)
elif len(tag) != 2:
raise ValueError("not a valid tuple-format tag")
else:
if any(part is not None and not isinstance(part, str) for part in tag):
raise TypeError("tuple-format tags must only contain str and None")
if tag[1] is None:
raise ValueError("tuple-format localname must not be None")
return tag
from .types import ( # NOQA: F401
Unknown,
AbstractCDataType,
AbstractElementType,
String,
Integer,
Float,
Bool,
DateTime,
Date,
Time,
Base64Binary,
HexBinary,
JID,
ConnectionLocation,
LanguageTag,
JSON,
TextChildMap,
EnumType,
EnumCDataType,
EnumElementType,
AbstractValidator,
RestrictToSet,
Nmtoken,
IsInstance,
NumericRange,
)
from .model import ( # NOQA: F401
UnknownChildPolicy,
UnknownAttrPolicy,
UnknownTextPolicy,
ValidateMode,
UnknownTopLevelTag,
Attr,
LangAttr,
ChildValue,
Child,
ChildFlag,
ChildList,
ChildLangMap,
ChildMap,
ChildTag,
ChildText,
Collector,
Text,
ChildValueList,
ChildValueMap,
ChildValueMultiMap,
ChildTextMap,
XSOParser,
SAXDriver,
XSO,
XSOEnumMixin,
CapturingXSO,
lang_attr,
capture_events,
events_to_sax,
AbstractTextChild,
)
from .model import _PropBase # NOQA: E402
NO_DEFAULT = _PropBase.NO_DEFAULT
del _PropBase
from .query import ( # NOQA: F401
where,
not_,
)
aioxmpp/xso/model.py 0000664 0000000 0000000 00000317024 14160146213 0015023 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: model.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
# .
#
########################################################################
"""
:mod:`aioxmpp.xso.model` --- Declarative-style XSO definition
#############################################################
See :mod:`aioxmpp.xso` for documentation.
"""
import abc
import collections
import copy
import enum
import logging
import sys
import xml.sax.handler
import lxml.sax
import sortedcollections
import multidict # get it from PyPI
from enum import Enum
from aioxmpp.utils import etree, namespaces
from . import query as xso_query
from . import types as xso_types
from . import tag_to_str, normalize_tag
from .. import structs
logger = logging.getLogger(__name__)
class UnknownChildPolicy(Enum):
"""
Describe the event which shall take place whenever a child element is
encountered for which no descriptor can be found to parse it.
.. attribute:: FAIL
Raise a :class:`ValueError`
.. attribute:: DROP
Drop and ignore the element and all of its children
"""
FAIL = 0
DROP = 1
class UnknownAttrPolicy(Enum):
"""
Describe the event which shall take place whenever a XML attribute is
encountered for which no descriptor can be found to parse it.
.. attribute:: FAIL
Raise a :class:`ValueError`
.. attribute:: DROP
Drop and ignore the attribute
"""
FAIL = 0
DROP = 1
class UnknownTextPolicy(Enum):
"""
Describe the event which shall take place whenever XML character data is
encountered on an object which does not support it.
.. attribute:: FAIL
Raise a :class:`ValueError`
.. attribute:: DROP
Drop and ignore the text
"""
FAIL = 0
DROP = 1
class ValidateMode(Enum):
"""
Control which ways to set a value in a descriptor are passed through a
validator.
.. attribute:: FROM_RECV
Values which are obtained from XML source are validated.
.. attribute:: FROM_CODE
Values which are set through attribute access are validated.
.. attribute:: ALWAYS
All values, whether set by attribute or obtained from XML source, are
validated.
"""
FROM_RECV = 1
FROM_CODE = 2
ALWAYS = 3
@property
def from_recv(self):
return self.value & 1
@property
def from_code(self):
return self.value & 2
class UnknownTopLevelTag(ValueError):
"""
Subclass of :class:`ValueError`. `ev_args` must be the arguments of the
``"start"`` event and are stored as the :attr:`ev_args` attribute for
inspection.
.. attribute:: ev_args
The `ev_args` passed to the constructor.
"""
def __init__(self, msg, ev_args):
super().__init__(msg + ": {}".format((ev_args[0], ev_args[1])))
self.ev_args = ev_args
class XSOList(list):
"""
A :class:`list` subclass; it provides the complete :class:`list` interface
with the addition of the following methods:
.. automethod:: filter
.. automethod:: filtered
In the future, methods to add indices to :class:`XSOList` instances may be
added; right now, there is no need for the huge complexity which would
arise from keeping the indices up-to-date with changes in the elements
attributes.
"""
def _filter_type(self, chained_results, type_):
return (obj for obj in chained_results if isinstance(obj, type_))
def _filter_lang(self, chained_results, lang):
# first, filter on availability of the "lang" attribute
result = [item
for item in chained_results
if hasattr(item, "lang") and item.lang is not None]
# get a sorted list of all languages in the current result set
languages = sorted(
{item.lang for item in result}
)
if not languages:
# no languages -> no results
result = iter([])
else:
# lookup a matching language
if isinstance(lang, structs.LanguageRange):
lang = [lang]
else:
lang = list(lang)
match = structs.lookup_language(languages, lang)
# no language? fallback is using the first one
if match is None:
match = languages[0]
result = (item for item in result if item.lang == match)
return result
def _filter_attrs(self, chained_results, attrs):
result = chained_results
for key, value in attrs.items():
result = (item for item in result
if hasattr(item, key) and getattr(item, key) == value)
return result
def filter(self, *, type_=None, lang=None, attrs={}):
"""
Return an iterable which produces a sequence of the elements inside
this :class:`XSOList`, filtered by the criteria given as arguments. The
function starts with a working sequence consisting of the whole list.
If `type_` is not :data:`None`, elements which are not an instance of
the given type are excluded from the working sequence.
If `lang` is not :data:`None`, it must be either a
:class:`~.structs.LanguageRange` or an iterable of language ranges. The
set of languages present among the working sequence is determined and
used for a call to
:class:`~.structs.lookup_language`. If the lookup returns a language,
all elements whose :attr:`lang` is different from that value are
excluded from the working sequence.
.. note::
If an iterable of language ranges is given, it is evaluated into a
list. This may be of concern if a huge iterable is about to be used
for language ranges, but it is an requirement of the
:class:`~.structs.lookup_language` function which is used under the
hood.
.. note::
Filtering by language assumes that the elements have a
:class:`~aioxmpp.xso.LangAttr` descriptor named ``lang``.
If `attrs` is not empty, the filter iterates over each `key`-`value`
pair. For each iteration, all elements which do not have an attribute
of the name in `key` or where that attribute has a value not equal to
`value` are excluded from the working sequence.
In general, the iterable returned from :meth:`filter` can only be used
once. It is dynamic in the sense that changes to elements which are in
the list *behind* the last element returned from the iterator will
still be picked up when the iterator is resumed.
"""
result = self
if type_ is not None:
result = self._filter_type(result, type_)
if lang is not None:
result = self._filter_lang(result, lang)
if attrs:
result = self._filter_attrs(result, attrs)
return result
def filtered(self, *, type_=None, lang=None, attrs={}):
"""
This method is a convencience wrapper around :meth:`filter` which
evaluates the result into a list and returns that list.
"""
return list(self.filter(type_=type_, lang=lang, attrs=attrs))
class PropBaseMeta(type):
def __instancecheck__(self, instance):
if (isinstance(instance, xso_query.BoundDescriptor) and
super().__instancecheck__(instance.xq_descriptor)):
return True
return super().__instancecheck__(instance)
class _PropBase(metaclass=PropBaseMeta):
class NO_DEFAULT:
def __repr__(self):
return ""
def __bool__(self):
raise TypeError("cannot convert {!r} to bool".format(self))
NO_DEFAULT = NO_DEFAULT()
class __INCOMPLETE:
def __repr__(self):
return ""
def __bool__(self):
raise TypeError("cannot convert {!r} to bool".format(self))
__INCOMPLETE = __INCOMPLETE()
def __init__(self, default=NO_DEFAULT,
*,
validator=None,
validate=ValidateMode.FROM_RECV):
super().__init__()
self.default = default
self.validate = validate
self.validator = validator
def _set(self, instance, value):
instance._xso_contents[self] = value
def __set__(self, instance, value):
if (self.default != value and
self.validate.from_code and
self.validator and
not self.validator.validate(value)):
raise ValueError("invalid value")
self._set(instance, value)
def _set_from_code(self, instance, value):
self.__set__(instance, value)
def _set_from_recv(self, instance, value):
if (self.default != value and
self.validate.from_recv and
self.validator and
not self.validator.validate(value)):
raise ValueError("invalid value")
self._set(instance, value)
def __get__(self, instance, type_):
if instance is None:
return xso_query.BoundDescriptor(
type_,
self,
xso_query.GetDescriptor,
)
try:
value = instance._xso_contents[self]
except KeyError:
if self.default is self.NO_DEFAULT:
raise AttributeError(
"attribute is unset ({} on instance of {})".format(
self, type_)
) from None
return self.default
if value is self.__INCOMPLETE:
raise AttributeError(
"attribute value is incomplete"
)
return value
def mark_incomplete(self, instance):
instance._xso_contents[self] = self.__INCOMPLETE
def validate_contents(self, instance):
try:
self.__get__(instance, type(instance))
except AttributeError as exc:
raise ValueError(str(exc)) from None
def to_node(self, instance, parent):
handler = lxml.sax.ElementTreeContentHandler(
makeelement=parent.makeelement)
handler.startDocument()
handler.startElementNS((None, "_"), None, {})
self.to_sax(instance, handler)
parent.extend(handler.etree.getroot())
class _TypedPropBase(_PropBase):
def __init__(self, *,
type_=xso_types.String(),
erroneous_as_absent=False,
**kwargs):
super().__init__(**kwargs)
self.type_ = type_
self.erroneous_as_absent = erroneous_as_absent
def __set__(self, instance, value):
if self.default is self.NO_DEFAULT or value != self.default:
value = self.type_.coerce(value)
super().__set__(instance, value)
class Text(_TypedPropBase):
"""
Character data contents of an XSO.
Note that this does not preserve the relative ordering of child elements
and character data pieces. This is known and a WONTFIX, as it is not
required in XMPP to keep that relative order: Elements either have
character data *or* other elements as children.
The `type_`, `validator`, `validate`, `default` and `erroneous_as_absent`
arguments behave like in :class:`Attr`.
.. automethod:: from_value
.. automethod:: to_sax
"""
def from_value(self, instance, value):
"""
Convert the given value using the set `type_` and store it into
`instance`’ attribute.
"""
try:
parsed = self.type_.parse(value)
except (TypeError, ValueError):
if self.erroneous_as_absent:
return False
raise
self._set_from_recv(instance, parsed)
return True
def to_sax(self, instance, dest):
"""
Assign the formatted value stored at `instance`’ attribute to the text
of `el`.
If the `value` is :data:`None`, no text is generated.
"""
value = self.__get__(instance, type(instance))
if value is None:
return
dest.characters(self.type_.format(value))
class _ChildPropBase(_PropBase):
"""
This is a base class for descriptors related to child :class:`XSO`
instances.
It provides a few implementation parts shared between :class:`Child`,
:class:`ChildList` and :class:`ChildMap`.
"""
def __init__(self, classes, default=None):
super().__init__(default)
self._classes = set()
self._tag_map = {}
for cls in classes:
self._register(cls)
def _process(self, instance, ev_args, ctx):
cls = self._tag_map[ev_args[0], ev_args[1]]
return (yield from cls.parse_events(ev_args, ctx))
def get_tag_map(self):
"""
Return a dictionary mapping the tags of the supported classes to the
classes themselves. Can be used to obtain a set of supported tags.
"""
return self._tag_map
def _register(self, cls):
if cls.TAG in self._tag_map:
raise ValueError("ambiguous children: {} and {} share the same "
"TAG".format(
self._tag_map[cls.TAG],
cls))
self._tag_map[cls.TAG] = cls
self._classes.add(cls)
class ChildValue(_ChildPropBase):
"""
Child element parsed using an :term:`Element Type`.
Descriptor represeting a child element as parsed using an element type.
:param type_: The element type to use to parse the child element.
:type type_: :class:`aioxmpp.xso.AbstractElementType`
The descriptor value will be the unpacked child element value. Upon
serialisation, the descriptor
"""
def __init__(self, type_):
super().__init__(type_.get_xso_types())
self.type_ = type_
def from_events(self, instance, ev_args, ctx):
xso = (yield from super()._process(instance, ev_args, ctx))
value = self.type_.unpack(xso)
self._set_from_recv(instance, value)
def to_sax(self, instance, dest):
value = self.__get__(instance, type(instance))
packed = self.type_.pack(value)
packed.xso_serialise_to_sax(dest)
class Child(_ChildPropBase):
"""
A single child element of any of the given XSO types.
:param classes: XSO types to support in this attribute
:type classes: iterable of :class:`aioxmpp.xso.XSO` subclasses
:param required: If true, parsing fails if the element is missing.
:type required: :class:`bool`
:param strict: Enable strict type checking on assigned values.
:type strict: :class:`bool`
The tags among the `classes` must be unique, otherwise :class:`ValueError`
is raised on construction.
Instead of the `default` argument like supplied by :class:`Attr`,
:class:`Child` only supports `required`: if `required` is a false value
(the default), a missing child is tolerated and :data:`None` is a valid
value for the described attribute. Otherwise, a missing matching child is
an error and the attribute cannot be set to :data:`None`.
If `strict` is true, only instances of the exact classes registered with
the descriptor can be assigned to it. Subclasses of the registered classes
also need to be registered explicitly to be allowed as types for values.
This comes with a performance impact on every write to the descriptor, so
it is disabled by default. It is recommended to enable this for descriptors
where applications may register additional classes, to protect them from
forgetting such a registration (which would cause issues with reception).
If during parsing, more than one child element with a tag matching one of
the :attr:`.XSO.TAG` values of the registered `classes` is encountered,
it is unspecified which child is taken.
.. automethod:: get_tag_map
.. automethod:: from_events
.. automethod:: to_sax
"""
def __init__(self, classes, required=False, strict=False):
super().__init__(
classes,
default=_PropBase.NO_DEFAULT if required else None
)
self.__strict = strict
@property
def required(self):
return self.default is _PropBase.NO_DEFAULT
@property
def strict(self):
return self.__strict
def __set__(self, instance, value):
if value is None and self.required:
raise ValueError("cannot set required member to None")
if (self.__strict and
value is not None and
type(value) not in self._classes):
raise TypeError("{!r} object is not a valid value".format(
type(value)
))
super().__set__(instance, value)
def __delete__(self, instance):
if self.required:
raise AttributeError("cannot delete required member")
try:
del instance._xso_contents[self]
except KeyError:
pass
def from_events(self, instance, ev_args, ctx):
"""
Detect the object to instantiate from the arguments `ev_args` of the
``"start"`` event. The new object is stored at the corresponding
descriptor attribute on `instance`.
This method is suspendable.
"""
obj = yield from self._process(instance, ev_args, ctx)
self.__set__(instance, obj)
return obj
def validate_contents(self, instance):
try:
obj = self.__get__(instance, type(instance))
except AttributeError:
raise ValueError("missing required member")
if obj is not None:
obj.validate()
def to_sax(self, instance, dest):
"""
Take the object associated with this descriptor on `instance` and
serialize it as child into the given :class:`lxml.etree.Element`
`parent`.
If the object is :data:`None`, no content is generated.
"""
obj = self.__get__(instance, type(instance))
if obj is None:
return
obj.xso_serialise_to_sax(dest)
class ChildList(_ChildPropBase):
"""
List of child elements of any of the given XSO classes.
The :class:`ChildList` works like :class:`Child`, with two key differences:
* multiple children which are matched by this descriptor get collected into
an :class:`~aioxmpp.xso.model.XSOList`.
* the default is fixed at an empty list.
* `required` is not supported
.. automethod:: from_events
.. automethod:: to_sax
"""
def __init__(self, classes):
super().__init__(classes)
def __get__(self, instance, type_):
if instance is None:
return xso_query.BoundDescriptor(
type_,
self,
xso_query.GetSequenceDescriptor,
)
return instance._xso_contents.setdefault(self, XSOList())
def _set(self, instance, value):
if not isinstance(value, list):
raise TypeError("expected list, but found {}".format(type(value)))
return super()._set(instance, value)
def from_events(self, instance, ev_args, ctx):
"""
Like :meth:`.Child.from_events`, but instead of replacing the attribute
value, the new object is appended to the list.
"""
obj = yield from self._process(instance, ev_args, ctx)
self.__get__(instance, type(instance)).append(obj)
return obj
def validate_contents(self, instance):
for child in self.__get__(instance, type(instance)):
child.validate()
def to_sax(self, instance, dest):
"""
Like :meth:`.Child.to_node`, but instead of serializing a single
object, all objects in the list are serialized.
"""
for obj in self.__get__(instance, type(instance)):
obj.xso_serialise_to_sax(dest)
class Collector(_PropBase):
"""
Catch-all descriptor collecting unhandled elements in an :mod:`lxml`
element tree.
When assigned to a class’ attribute, it collects all children which are not
known to any other descriptor into an XML tree. The root node has the tag
of the XSO class it pertains to.
The default is fixed to the empty root node.
.. versionchanged:: 0.10
Before the subtrees were collected in a list. This was changed to an
ElementTree to allow using XPath over all collected elements. Most code
should not be affected by this, since the interface is very similar.
Assignment is now forbidden. Use ``[:] =`` instead.
.. automethod:: from_events
.. automethod:: to_sax
"""
def __init__(self):
super().__init__(default=[])
def __get__(self, instance, type_):
if instance is None:
return xso_query.BoundDescriptor(
type_,
self,
xso_query.GetSequenceDescriptor,
)
try:
return instance._xso_contents[self]
except KeyError:
res = etree.Element(tag_to_str(instance.TAG))
instance._xso_contents[self] = res
return res
def _set(self, instance, value):
raise AttributeError("Collector attribute cannot be assigned to")
def from_events(self, instance, ev_args, ctx):
"""
Collect the events and convert them to a single XML subtree, which then
gets appended to the list at `instance`. `ev_args` must be the
arguments of the ``"start"`` event of the new child.
This method is suspendable.
"""
# goal: collect all elements starting with the element for which we got
# the start-ev_args in a lxml.etree.Element.
def make_from_args(ev_args, parent):
el = etree.SubElement(parent,
tag_to_str((ev_args[0], ev_args[1])))
for key, value in ev_args[2].items():
el.set(tag_to_str(key), value)
return el
root_el = make_from_args(ev_args,
self.__get__(instance, type(instance)))
# create an element stack
stack = [root_el]
while stack:
# we get send all sax-ish events until we return. we return when
# the stack is empty, i.e. when our top element ended.
ev_type, *ev_args = yield
if ev_type == "start":
# new element started, create and push to stack
stack.append(make_from_args(ev_args, stack[-1]))
elif ev_type == "text":
# text for current element
curr = stack[-1]
if curr.text is not None:
curr.text += ev_args[0]
else:
curr.text = ev_args[0]
elif ev_type == "end":
# element ended, remove from stack (it is already appended to
# the current element)
stack.pop()
else:
# not in coverage -- this is more like an assertion
raise ValueError(ev_type)
def to_sax(self, instance, dest):
for node in self.__get__(instance, type(instance)):
lxml.sax.saxify(node, _CollectorContentHandlerFilter(dest))
class Attr(Text):
"""
A single XML attribute.
:param tag: The tag identifying the attribute.
:type tag: :class:`str` or :class:`tuple` of :term:`Namespace URI` and
:term:`Local Name`.
If the `tag` is a :class:`str`, it is converted using ``(None, tag)``,
thus representing an unnamespaced attribute. Note that most attributes
are unnamespaced; namespaced attributes always have a namespace prefix
on them. Attributes without a namespace prefix, in XML, are unnamespaced
(*not* part of the current prefixless namespace).
.. note::
The following arguments occur at several of the descriptor classes,
and are all available at :class:`Attr`. Their semantics are identical
on other classes, transferred to the respective use there.
(For example, the :class:`ChildText` descriptor is obviously not
working with attributes, so the phrase "if the attribute is absent"
should be mentally translated to "if the child element is absent".)
:param type_: A character data type to interpret the XML character data.
:type type_: :class:`~.xso.AbstractCDataType`
:param validator: Optional validator object
:type validator: :class:`~.xso.AbstractValidator`
:param validate: Control when the `validator` is enforced.
:type validate: :class:`ValidateMode`
:param default: The value which the attribute has if no value has been
assigned.
:param missing: Callback function to handle a missing attribute in the
input.
:type missing: :data:`None` or unary function
:param erroneous_as_absent: Treat an erroneous value (= the `type_` raises
:class:`ValueError` or :class:`TypeError` while parsing) as if the
attribute was not present.
:type erroneous_as_absent: :class:`bool`
The `type_` must be a :term:`Character Data Type`, i.e. an instance of a
subclass of :class:`AbstractCDataType`. By default, it is
:class:`aioxmpp.xso.String`. The `type_` is used to parse the XML character
data into python types as appropriate. Errors during this parsing count
as parsing errors of the whole XSO (subtree), unless `erroneous_as_absent`
is set to true. In that case, the attribute is simply treated as absent
if parsing the value fails.
If the XML attribute has no default assigned, the descriptor will appear
to have the `default` value. If no `default` is given (the default) and
an attempt is made to access the described attribute,
:class:`AttributeError` is raised as you would expect from any normal
attribute.
If a `default` is given, the `default` is also returned after a `del`
operation; otherwise, `del` behaves as for any normal attribute.
Another peculiar property of the `default` is that it does not need to
conform to the `validator` or `type_`. If the descriptor is set to the
default value, it is *not* emitted on the output.
In addition to the `default`, it is possible to inject attribute values
at parse time using the `missing` callback. `missing` must be None or a
function taking a single argument. If it is not None, it will be called
with the parsing :class:`Context` as its only argument when a missing
attribute is encountered. The return value, unless :data:`None`, is used
as value for the descriptor. If the return value is :data:`None`, the
attribute is treated like any normal missing attribute.
It is possible to add validation to values received over the wire or
assigned to the descriptor. The `validator` object controls *what*
validation occurs and `validate` controls *when* validation occurs.
`validate` must be a member of the :class:`ValidateMode` enumeration (see
there for the semantics of the values). `validator` must be an object
implementing the interface defined by :class:`AbstractValidator`.
Note that validation is independent of the conversions applied by `type_`.
Validation always happens on the parsed type and happens before
serialisation. Thus, if `type_` is not :class:`aioxmpp.xso.String`, the
validator will not receive a :class:`str` object to operate on.
.. seealso::
:class:`LangAttr`, which is a subclass of :class:`Attr` specialized for
describing ``xml:lang`` attributes.
.. note::
The `default` argument does not need to comply with either `type_` or
`validator`. This can be used to convey meaning with the absence of the
attribute. Note that assigning the default value is not possible if it
does not comply with `type_` or `validator` and the ``del`` operator
must be used instead.
.. automethod:: from_value
.. automethod:: handle_missing
.. automethod:: to_dict
"""
def __init__(self, tag, *,
type_=xso_types.String(),
missing=None,
**kwargs):
super().__init__(type_=type_, **kwargs)
self.tag = normalize_tag(tag)
self.missing = missing
def __set__(self, instance, value):
super().__set__(instance, value)
def __delete__(self, instance):
try:
del instance._xso_contents[self]
except KeyError:
pass
def handle_missing(self, instance, ctx):
"""
Handle a missing attribute on `instance`. This is called whenever no
value for the attribute is found during parsing. The call to
:meth:`missing` is independent of the value of `required`.
If the `missing` callback is not :data:`None`, it is called with the
`instance` and the `ctx` as arguments. If the returned value is not
:data:`None`, it is used as the value of the attribute (validation
takes place as if the value had been set from the code, not as if the
value had been received from XML) and the handler returns.
If the `missing` callback is :data:`None` or returns :data:`None`, the
handling continues as normal: if `required` is true, a
:class:`ValueError` is raised.
"""
if self.missing is not None:
value = self.missing(instance, ctx)
if value is not None:
self._set_from_code(instance, value)
return
if self.default is _PropBase.NO_DEFAULT:
raise ValueError("missing attribute {} on {}".format(
tag_to_str(self.tag),
tag_to_str(instance.TAG),
))
# no need to set explicitly, it will be handled by _PropBase.__get__
def validate_contents(self, instance):
try:
self.__get__(instance, type(instance))
except AttributeError:
raise ValueError("non-None value required for {}".format(
tag_to_str(self.tag)
)) from None
def to_dict(self, instance, d):
"""
Override the implementation from :class:`Text` by storing the formatted
value in the XML attribute instead of the character data.
If the value is :data:`None`, no element is generated.
"""
value = self.__get__(instance, type(instance))
if value == self.default:
return
d[self.tag] = self.type_.format(value)
class LangAttr(Attr):
"""
Special handler for the ``xml:lang`` attribute.
An attribute representing the ``xml:lang`` attribute, including inheritance
semantics.
This is a subclass of :class:`Attr` which takes care of inheriting the
``xml:lang`` value of the parent. The `tag` is set to the
``(namespaces.xml, "lang")`` value to match ``xml:lang`` attributes.
`type_` is a :class:`xso.LanguageTag` instance and `missing` is set to
:func:`lang_attr`.
Note that :class:`LangAttr` overrides `default` to be :data:`None` by
default.
"""
def __init__(self, default=None, **kwargs):
super().__init__(
(namespaces.xml, "lang"),
default=default,
type_=xso_types.LanguageTag(),
missing=lang_attr
)
class ChildText(_TypedPropBase):
"""
Character data of a single child element matching the given tag.
When assigned to a class’ attribute, it binds that attribute to the XML
character data of a child element with the given `tag`. `tag` must be a
valid input to :func:`normalize_tag`.
:param child_policy: The policy to apply when children are found in the
child element whose text this descriptor represents.
:type child_policy: :class:`UnknownChildPolicy`
:param attr_policy: The policy to apply when attributes are found at the
child element whose text this descriptor represents.
:type attr_policy: :class:`UnknownAttrPolicy`
The `type_`, `validate`, `validator`, `default` and `erroneous_as_absent`
arguments behave like in :class:`Attr`.
`declare_prefix` works as for :class:`ChildTag`.
`child_policy` and `attr_policy` describe how the parser behaves when an
unknown child or attribute (respectively) is encountered on the child
element whose text this descriptor represents. See
:class:`UnknownChildPolicy` and :class:`UnknownAttrPolicy` for the possible
behaviours.
.. automethod:: get_tag_map
.. automethod:: from_events
.. automethod:: to_sax
"""
def __init__(self, tag,
*,
child_policy=UnknownChildPolicy.FAIL,
attr_policy=UnknownAttrPolicy.FAIL,
declare_prefix=False,
**kwargs):
super().__init__(**kwargs)
self.tag = normalize_tag(tag)
self.child_policy = child_policy
self.attr_policy = attr_policy
self.declare_prefix = declare_prefix
def get_tag_map(self):
"""
Return an iterable yielding :attr:`tag`.
This is for compatibility with the :class:`Child` interface.
"""
return {self.tag}
def from_events(self, instance, ev_args, ctx):
"""
Starting with the element to which the start event information in
`ev_args` belongs, parse text data. If any children are encountered,
:attr:`child_policy` is enforced (see
:class:`UnknownChildPolicy`). Likewise, if the start event contains
attributes, :attr:`attr_policy` is enforced
(c.f. :class:`UnknownAttrPolicy`).
The extracted text is passed through :attr:`type_` and
:attr:`validator` and if it passes, stored in the attribute on the
`instance` with which the property is associated.
This method is suspendable.
"""
# goal: take all text inside the child element and collect it as
# attribute value
attrs = ev_args[2]
if attrs and self.attr_policy == UnknownAttrPolicy.FAIL:
raise ValueError("unexpected attribute (at text only node)")
parts = []
while True:
ev_type, *ev_args = yield
if ev_type == "text":
# collect ALL TEH TEXT!
parts.append(ev_args[0])
elif ev_type == "start":
# ok, a child inside the child was found, we look at our policy
# to see what to do
yield from enforce_unknown_child_policy(
self.child_policy,
ev_args)
elif ev_type == "end":
# end of our element, return
break
joined = "".join(parts)
try:
parsed = self.type_.parse(joined)
except (ValueError, TypeError):
if self.erroneous_as_absent:
return
raise
self._set_from_recv(instance, parsed)
def to_sax(self, instance, dest):
"""
Create a child node at `parent` with the tag :attr:`tag`. Set the text
contents to the value of the attribute which this descriptor represents
at `instance`.
If the value is :data:`None`, no element is generated.
"""
value = self.__get__(instance, type(instance))
if value == self.default:
return
if self.declare_prefix is not False and self.tag[0]:
dest.startPrefixMapping(self.declare_prefix, self.tag[0])
dest.startElementNS(self.tag, None, {})
try:
dest.characters(self.type_.format(value))
finally:
dest.endElementNS(self.tag, None)
if self.declare_prefix is not False and self.tag[0]:
dest.endPrefixMapping(self.declare_prefix)
class ChildMap(_ChildPropBase):
"""
Dictionary holding child elements of one or more XSO classes.
The :class:`ChildMap` class works like :class:`ChildList`, but instead of
storing the child objects in a list, they are stored in a map which
contains an :class:`~aioxmpp.xso.model.XSOList` of objects for each tag.
`key` may be callable. If it is given, it is used while parsing to
determine the dictionary key under which a newly parsed XSO will be
put. For that, the `key` callable is called with the newly parsed XSO as
the only argument and is expected to return the key.
.. automethod:: from_events
.. automethod:: to_sax
The following utility function is useful when filling data into descriptors
using this class:
.. automethod:: fill_into_dict
"""
def __init__(self, classes, *, key=None):
super().__init__(classes)
self.key = key or (lambda obj: obj.TAG)
def __get__(self, instance, type_):
if instance is None:
return xso_query.BoundDescriptor(
type_,
self,
xso_query.GetMappingDescriptor,
)
return instance._xso_contents.setdefault(
self,
collections.defaultdict(XSOList)
)
def __set__(self, instance, value):
raise AttributeError("ChildMap attribute cannot be assigned to")
def _set(self, instance, value):
if not isinstance(value, dict):
raise TypeError("expected dict, but found {}".format(type(value)))
return super()._set(instance, value)
def fill_into_dict(self, items, dest):
"""
Take an iterable of `items` and group it into the given `dest` dict,
using the :attr:`key` function.
The `dest` dict must either already contain the keys which are
generated by the :attr:`key` function for the items in `items`, or must
default them suitably. The values of the affected keys must be
sequences or objects with an :meth:`append` method which does what you
want it to do.
"""
for item in items:
dest[self.key(item)].append(item)
def from_events(self, instance, ev_args, ctx):
"""
Like :meth:`.ChildList.from_events`, but the object is appended to the
list associated with its tag in the dict.
"""
tag = ev_args[0], ev_args[1]
cls = self._tag_map[tag]
obj = yield from cls.parse_events(ev_args, ctx)
mapping = self.__get__(instance, type(instance))
mapping[self.key(obj)].append(obj)
def validate_contents(self, instance):
mapping = self.__get__(instance, type(instance))
for objects in mapping.values():
for obj in objects:
obj.validate()
def to_sax(self, instance, dest):
"""
Serialize all objects in the dict associated with the descriptor at
`instance` to the given `parent`.
The order of elements within a tag is preserved; the order of the tags
relative to each other is undefined.
"""
for items in self.__get__(instance, type(instance)).values():
for obj in items:
obj.xso_serialise_to_sax(dest)
class ChildLangMap(ChildMap):
"""
Shorthand for a dictionary of child elements keyed by the language
attribute.
The :class:`ChildLangMap` class is a specialized version of the
:class:`ChildMap`, which uses a `key` function to group the children by
their XML language tag.
It is expected that the language tag is available as ``lang`` attribute on
the objects stored in this map.
"""
@staticmethod
def _lang_key(obj):
return obj.lang
def __init__(self, classes, **kwargs):
super().__init__(classes, key=self._lang_key, **kwargs)
class ChildTag(_PropBase):
"""
Tag of a single child element with one of the given tags.
:param tags: The tags to match on.
:type tags: iterable of valid arguments to :func:`normalize_tags` or
a :class:`enum.Enum` subclass
:param text_policy: Determine how text content on the child elements is
handled.
:type text_policy: :class:`UnknownTextPolicy`
:param child_policy: Determine how elements nested in the child elements
are handled.
:type child_policy: :class:`UnknownChildPolicy`
:param attr_policy: Determine how attributes on the child elements are
handled.
:type attr_policy: :class:`UnknownAttrPolicy`
:param allow_none: If true, :data:`None` is used as the default if no
child matching the tags is found and represents the absence of the
child for serialisation.
:type allow_none: :class:`bool`
:param declare_prefix: Which namespace prefix, if any, to declare on the
child element for its namespace.
:type declare_prefix: :data:`False`, :data:`None` or :class:`str`
When assigned to a class’ attribute, this descriptor represents the
presence or absence of a single child with a tag from a given set of valid
tags.
`tags` must be an iterable of valid arguments to
:func:`normalize_tag` or an :class:`enum.Enum` whose values are
valid arguments to :func:`normalize_tag`. If :func:`normalize_tag`
returns a false value (such as :data:`None`) as `namespace_uri`,
it is replaced with `default_ns` (defaulting to :data:`None`,
which makes this sentence a no-op). This allows a benefit to
readability if you have many tags which share the same namespace.
This is, however, not allowed for tags given as enumeration.
`text_policy`, `child_policy` and `attr_policy` describe the behaviour if
the child element unexpectedly has text, children or attributes,
respectively. The default for each is to fail with a :class:`ValueError`.
If `allow_none` is :data:`True`, assignment of :data:`None` to the
attribute to which this descriptor belongs is allowed and represents the
absence of the child element.
If `declare_prefix` is not :data:`False` (note that :data:`None` is a
valid, non-:data:`False` value in this context!), the namespace is
explicitly declared using the given prefix when serializing to SAX.
.. automethod:: from_events
.. automethod:: to_sax
"""
class ConvertEnum:
def __init__(self, enum_=None):
self._enum = enum_
def parse(self, v):
return self._enum(normalize_tag(v))
def format(self, v):
return tag_to_str(v.value)
class ConvertTag:
def parse(self, v):
return normalize_tag(v)
def format(self, v):
return tag_to_str(v)
def __init__(self, tags, *,
default_ns=None,
text_policy=UnknownTextPolicy.FAIL,
child_policy=UnknownChildPolicy.FAIL,
attr_policy=UnknownAttrPolicy.FAIL,
allow_none=False,
declare_prefix=False):
def normalize_tags(tags):
return {
(ns or default_ns, localname)
for ns, localname in map(normalize_tag, tags)
}
if isinstance(tags, type(enum.Enum)):
self._converter = self.ConvertEnum(tags)
values = list(tags)
tags = normalize_tags([tag.value for tag in tags])
else:
self._converter = self.ConvertTag()
tags = normalize_tags(tags)
values = tags
super().__init__(
default=None if allow_none else _PropBase.NO_DEFAULT,
validator=xso_types.RestrictToSet(values),
validate=ValidateMode.ALWAYS)
self.child_map = tags
self.text_policy = text_policy
self.attr_policy = attr_policy
self.child_policy = child_policy
self.declare_prefix = declare_prefix
@property
def allow_none(self):
return self.default is not _PropBase.NO_DEFAULT
def get_tag_map(self):
return self.child_map
def from_events(self, instance, ev_args, ctx):
attrs = ev_args[2]
if attrs and self.attr_policy == UnknownAttrPolicy.FAIL:
raise ValueError("unexpected attributes")
tag = ev_args[0], ev_args[1]
while True:
ev_type, *ev_args = yield
if ev_type == "text":
if self.text_policy == UnknownTextPolicy.FAIL:
raise ValueError("unexpected text")
elif ev_type == "start":
yield from enforce_unknown_child_policy(
self.child_policy,
ev_args)
elif ev_type == "end":
break
self._set_from_recv(instance, self._converter.parse(tag))
def to_sax(self, instance, dest):
value = self.__get__(instance, type(instance))
if value is None:
return
value = normalize_tag(self._converter.format(value))
if self.declare_prefix is not False and value[0]:
dest.startPrefixMapping(self.declare_prefix, value[0])
dest.startElementNS(value, None, {})
dest.endElementNS(value, None)
if self.declare_prefix is not False and value[0]:
dest.endPrefixMapping(self.declare_prefix)
class ChildFlag(_PropBase):
"""
Presence of a child element with the given tag, as boolean.
:param tag: The tag of the child element to use as flag.
:type tag: :class:`str` or :class:`tuple`
:param text_policy: Determine how text content on the child elements is
handled.
:type text_policy: :class:`UnknownTextPolicy`
:param child_policy: Determine how elements nested in the child elements
are handled.
:type child_policy: :class:`UnknownChildPolicy`
:param attr_policy: Determine how attributes on the child elements are
handled.
:type attr_policy: :class:`UnknownAttrPolicy`
:type declare_prefix: :data:`False`, :data:`None` or :class:`str`
When used as a :class:`XSO` descriptor, it represents the presence or
absence of a single child with the given `tag`. The presence or absence is
represented by the values :data:`True` and :data:`False` respectively.
`tag` must be a valid tag.
The default value for attributes using this descriptor is :data:`False`.
`text_policy`, `child_policy` and `attr_policy` describe the behaviour if
the child element unexpectedly has text, children or attributes,
respectively. The default for each is to fail with a :class:`ValueError`.
If `declare_prefix` is not :data:`False` (note that :data:`None` is a
valid, non-:data:`False` value in this context!), the namespace is
explicitly declared using the given prefix when serializing to SAX.
"""
def __init__(self, tag,
text_policy=UnknownTextPolicy.FAIL,
child_policy=UnknownChildPolicy.FAIL,
attr_policy=UnknownAttrPolicy.FAIL,
declare_prefix=False):
super().__init__(
default=False,
)
self.tag = normalize_tag(tag)
self.text_policy = text_policy
self.attr_policy = attr_policy
self.child_policy = child_policy
self.declare_prefix = declare_prefix
def get_tag_map(self):
return {self.tag}
def from_events(self, instance, ev_args, ctx):
attrs = ev_args[2]
if attrs and self.attr_policy == UnknownAttrPolicy.FAIL:
raise ValueError("unexpected attributes")
while True:
ev_type, *ev_args = yield
if ev_type == "text":
if self.text_policy == UnknownTextPolicy.FAIL:
raise ValueError("unexpected text")
elif ev_type == "start":
yield from enforce_unknown_child_policy(
self.child_policy,
ev_args)
elif ev_type == "end":
break
self._set_from_recv(instance, True)
def to_sax(self, instance, dest):
value = self.__get__(instance, type(instance))
if not value:
return
if self.declare_prefix is not False and self.tag[0]:
dest.startPrefixMapping(self.declare_prefix, self.tag[0])
dest.startElementNS(self.tag, None, {})
dest.endElementNS(self.tag, None)
if self.declare_prefix is not False and self.tag[0]:
dest.endPrefixMapping(self.declare_prefix)
class ChildValueList(_ChildPropBase):
"""
List of child elements parsed using the given :term:`Element Type`.
:param type_: Type describing the subtree to convert to python values.
:type type_: :class:`~.xso.AbstractElementType`
:param container_type: Type of the container to use.
:type container_type: Subclass of :class:`~collections.abc.MutableSequence`
or :class:`~collections.abc.MutableSet`
This descriptor parses the XSO classes advertised by the `type_` (via
:meth:`~.AbstractElementType.get_xso_types`) and exposes the unpacked
values in a container.
The optional `container_type` argument must, if given, be a callable which
returns a mutable container supporting either :meth:`add` or :meth:`append`
of the values used with the `type_` and iteration. It will be used instead
of :class:`list` to create the values for the descriptor.
.. versionadded:: 0.5
"""
def __init__(self, type_, *, container_type=list):
super().__init__(type_.get_xso_types())
self.type_ = type_
self.container_type = container_type
try:
self._add = container_type.append
except AttributeError:
self._add = container_type.add
def __get__(self, instance, type_):
if instance is None:
return xso_query.BoundDescriptor(
type_,
self,
xso_query.GetSequenceDescriptor,
expr_kwargs={"sequence_factory": self.container_type},
)
try:
return instance._xso_contents[self]
except KeyError:
result = self.container_type()
instance._xso_contents[self] = result
return result
def __set__(self, instance, value):
raise AttributeError("child value list not writable")
def from_events(self, instance, ev_args, ctx):
obj = yield from self._process(instance, ev_args, ctx)
value = self.type_.unpack(obj)
self._add(self.__get__(instance, type(instance)), value)
def to_sax(self, instance, dest):
for value in self.__get__(instance, type(instance)):
self.type_.pack(value).xso_serialise_to_sax(dest)
class ChildValueMap(_ChildPropBase):
"""
Dictiorary of child elements parsed using the given :term:`Element Type`.
:param type_: Type describing the subtree to convert to pairs of key and
value.
:type type_: :class:`~.xso.AbstractElementType`
:param mapping_type: Type of the mapping to use.
:type mapping_type: Subclass of :class:`~collections.abc.MutableMapping`
This works very similar to :class:`ChildValueList`, but instead of a
mutable sequence, the value of the descriptor is a mutable mapping.
The `type_` must return key-value pairs from
:meth:`.xso.AbstractElementType.unpack` and must accept such key-value
pairs in :meth:`.xso.AbstractElementType.pack`.
The optional `mapping_type` argument must, if given, be a callable which
returns a :class:`collections.abc.MutableMapping` supporting the keys and
values used by the `type_`. It will be used instead of :class:`dict` to
create the values for the descriptor. A possible use-case is using
:class:`.structs.LanguageMap` together with :class:`~.xso.TextChildMap`.
.. seealso::
:class:`ChildTextMap` for a specialised version to deal with
:class:`AbstractTextChild` subclasses.
.. versionadded:: 0.5
"""
def __init__(self, type_, *, mapping_type=dict):
super().__init__(type_.get_xso_types())
self.type_ = type_
self.mapping_type = mapping_type
def __get__(self, instance, type_):
if instance is None:
return xso_query.BoundDescriptor(
type_,
self,
xso_query.GetMappingDescriptor,
expr_kwargs={"mapping_factory": self.mapping_type}
)
try:
return instance._xso_contents[self]
except KeyError:
result = self.mapping_type()
instance._xso_contents[self] = result
return result
def __set__(self, instance, value):
raise AttributeError("child value map not writable")
def to_sax(self, instance, dest):
for item in self.__get__(instance, type(instance)).items():
self.type_.pack(item).xso_serialise_to_sax(dest)
def from_events(self, instance, ev_args, ctx):
obj = yield from self._process(instance, ev_args, ctx)
key, value = self.type_.unpack(obj)
self.__get__(instance, type(instance))[key] = value
class ChildValueMultiMap(_ChildPropBase):
"""
Multi-dict of child elements parsed using the given :term:`Element Type`.
:param type_: Type describing the subtree to convert to pairs of key and
value.
:type type_: :class:`~.xso.AbstractElementType`
This is very similar to :class:`ChildValueMap`, but it uses a
:class:`multidict.MultiDict` as storage. Interface-compatible classes can
be substituted by passing them to `mapping_type`. Candidate for that are
:class:`multidict.CIMultiDict`.
.. note::
:class:`multidict.MultiDict` (even the non-case-insensitve version)
requires that the keys are strings, so there is only limited use in
the context of :mod:`aioxmpp`.
.. versionadded:: 0.6
"""
def __init__(self, type_, *, mapping_type=multidict.MultiDict):
super().__init__(type_.get_xso_types())
self.type_ = type_
self.mapping_type = mapping_type
def __get__(self, instance, type_):
if instance is None:
return xso_query.BoundDescriptor(
type_,
self,
xso_query.GetMappingDescriptor,
expr_kwargs={"mapping_factory": self.mapping_type},
)
try:
return instance._xso_contents[self]
except KeyError:
result = self.mapping_type()
instance._xso_contents[self] = result
return result
def __set__(self, instance, value):
raise AttributeError("child value multi map not writable")
def to_sax(self, instance, dest):
for key, value in self.__get__(instance, type(instance)).items():
self.type_.pack((key, value)).xso_serialise_to_sax(dest)
def from_events(self, instance, ev_args, ctx):
obj = yield from self._process(instance, ev_args, ctx)
key, value = self.type_.unpack(obj)
self.__get__(instance, type(instance)).add(key, value)
class ChildTextMap(ChildValueMap):
"""
Dictionary of character data in child elements keyed by the language
attribute.
A specialised version of :class:`ChildValueMap` which uses
:class:`TextChildMap` together with :class:`.structs.LanguageMap` to
convert the :class:`AbstractTextChild` subclass `xso_type` to and from
a language-text mapping.
If instead of an :class:`XSO` a tag is passed (that is, a valid
argument to :func:`normalize_tag`) an :class:`AbstractTextChild`
instance is created on demand.
For an example, see :class:`.Message`.
"""
def __init__(self, xso_type):
if not isinstance(xso_type, XMLStreamClass):
tag = normalize_tag(xso_type)
xso_type = type(
"TextChild" + tag[1],
(AbstractTextChild,),
{"TAG": tag},
)
super().__init__(
xso_types.TextChildMap(xso_type),
mapping_type=structs.LanguageMap
)
def _mark_attributes_incomplete(attrs, obj):
for attr in attrs:
attr.mark_incomplete(obj)
class XMLStreamClass(xso_query.Class, abc.ABCMeta):
"""
This metaclass is used to implement the fancy features of :class:`.XSO`
classes and instances. Its documentation details on some of the
restrictions and features of XML Stream Classes.
.. note::
There should be no need to use this metaclass directly when implementing
your own XSO classes. Instead, derive from :class:`~.xso.XSO`.
The following restrictions apply when a class uses the
:class:`XMLStreamClass` metaclass:
1. At no point in the inheritance tree there must exist more than one
distinct :class:`~.xso.Text` descriptor. It is possible to inherit two
identical text descriptors from several base classes though.
2. The above applies equivalently for :class:`~.xso.Collector`
descriptors.
3. At no point in the inheritance tree there must exist more than one
:class:`~.xso.Attr` descriptor which handles a given attribute tag. Like
with :class:`~.xso.Text`, it is allowed that the same
:class:`~.xso.Attr` descriptor is inherited through multiple paths from
parent classes.
4. The above applies likewise for element tags and :class:`~.xso.Child` (or
similar) descriptors.
Objects of this metaclass (i.e. classes) have some useful attributes. The
following attributes are gathered from the namespace of the class, by
collecting the different XSO-related descriptors:
.. attribute:: TEXT_PROPERTY
The :class:`~.xso.Text` descriptor object associated with this
class. This is :data:`None` if no attribute using that descriptor is
declared on the class.
.. attribute:: COLLECTOR_PROPERTY
The :class:`~.xso.Collector` descriptor object associated with this
class. This is :data:`None` if no attribute using that descriptor is
declared on the class.
.. attribute:: ATTR_MAP
A dictionary mapping attribute tags to the :class:`~.xso.Attr`
descriptor objects for these attributes.
.. attribute:: CHILD_MAP
A dictionary mapping element tags to the :class:`~.xso.Child` (or
similar) descriptor objects which accept these child elements.
.. attribute:: CHILD_PROPS
A set of all :class:`~.xso.Child` (or :class:`~.xso.ChildList`)
descriptor objects of this class.
.. attribute:: DECLARE_NS
A dictionary which defines the namespace mappings which shall be
declared when serializing this element. It must map namespace prefixes
(such as :data:`None` or ``"foo"``) to namespace URIs.
For maximum compatibility with legacy XMPP implementations (I’m looking
at you, ejabberd!), :attr:`DECLARE_NS` is set by this metaclass unless
it is provided explicitly when declaring the class:
* If no :attr:`TAG` is set, :attr:`DECLARE_NS` is also not set. The
attribute does not exist on the class in that case, unless it is
inherited from a base class.
* If :attr:`TAG` is set and at least one base class has a
:attr:`DECLARE_NS`, :attr:`DECLARE_NS` is not auto generated, so that
inheritance can take place.
* If :attr:`TAG` is set and has a namespace (and no base class has a
:attr:`DECLARE_NS`), :attr:`DECLARE_NS` is set to
``{ None: namespace }``, where ``namespace`` is the namespace of the
:attr:`TAG`.
* If :attr:`TAG` is set and does not have a namespace,
:attr:`DECLARE_NS` is set to the empty dict. This should not occur
outside testing, and support for tags without namespace might be
removed in future versions.
.. warning::
It is discouraged to use namespace prefixes of the format
``"ns{:d}".format(n)``, for any given number `n`. These prefixes are
reserved for ad-hoc namespace declarations, and attempting to use
them may have unwanted side-effects.
.. versionchanged:: 0.4
The automatic generation of the :attr:`DECLARE_NS` attribute was
added in 0.4.
.. attribute:: __slots__
The metaclass automatically sets this attribute to the empty tuple,
unless a different value is set in the class or `protect` is passed as
false to the metaclass.
Thus, to disable the automatic setting of :attr:`__slots__`, inherit for
example like this::
class MyXSO(xso.XSO, protect=False):
pass
The rationale for this is that attributes on XSO instances are magic.
Having a typo in an attribute may fail non-obviously, if it causes an
entirely different semantic to be invoked at the peer (for example the
:attr:`.Message.type_` attribute).
Setting :attr:`__slots__` to empty by default prevents assigning any
attribute not bound to an descriptor.
.. seealso::
:ref:`slots`
The official Python documentation describes the semantics of the
:attr:`__slots__` attribute in more detail.
:class:`~.xso.XSO` automatically sets a sensible :attr:`__slots__`
(including ``__weakref__``, but not ``__dict__``).
.. versionadded:: 0.6
.. note::
If you need to stay compatible with versions before 0.6 *and* have
arbitrary attributes writable, the correct way of doing things is to
explicitly set :attr:`__slots__` to ``("__dict__",)`` in your class.
You cannot use `protect` because it is not known in pre-0.6 versions.
.. note::
:class:`~.xso.XSO` defines defaults for more attributes which also
must be present on objects which are used as XSOs.
When inheriting from :class:`XMLStreamClass` objects, the properties are
merged sensibly.
Rebinding attributes of :class:`XMLStreamClass` instances (i.e. classes
using this metaclass) is somewhat restricted. The following rules cannot be
broken, attempting to do so will result in :class:`TypeError` being raised
when setting the attribute:
1. Existing descriptors for XSO purposes (such as :class:`.xso.Attr`)
cannot be removed (either by assigning a new value to the name they are
bound to or deleting the name).
2. New descriptors can only be added if they do not violate the rules
stated at the beginning of the :class:`XMLStreamClass` documentation.
3. New descriptors can only be added if no subclasses exist (see
:meth:`.xso.XSO.register_child` for reasons why).
"""
def __new__(mcls, name, bases, namespace, protect=True):
text_property = None
child_map = {}
child_props = sortedcollections.OrderedSet()
attr_map = {}
collector_property = None
for base in reversed(bases):
if not isinstance(base, XMLStreamClass):
continue
if base.TEXT_PROPERTY is not None:
if (text_property is not None and
base.TEXT_PROPERTY.xq_descriptor is not text_property):
raise TypeError("multiple text properties in inheritance")
text_property = base.TEXT_PROPERTY.xq_descriptor
for key, prop in base.CHILD_MAP.items():
try:
existing = child_map[key]
except KeyError:
child_map[key] = prop
else:
if existing is not prop:
raise TypeError("ambiguous Child properties inherited")
child_props |= base.CHILD_PROPS
for key, prop in base.ATTR_MAP.items():
try:
existing = attr_map[key]
except KeyError:
attr_map[key] = prop
else:
if existing is not prop:
raise TypeError("ambiguous Attr properties inherited")
if base.COLLECTOR_PROPERTY is not None:
if (collector_property is not None and
base.COLLECTOR_PROPERTY.xq_descriptor is not
collector_property):
raise TypeError("multiple collector properties in "
"inheritance")
collector_property = base.COLLECTOR_PROPERTY.xq_descriptor
for attrname, obj in namespace.items():
if isinstance(obj, Attr):
if obj.tag in attr_map:
raise TypeError("ambiguous Attr properties")
attr_map[obj.tag] = obj
elif isinstance(obj, Text):
if text_property is not None:
raise TypeError("multiple Text properties on XSO class")
text_property = obj
elif isinstance(obj, (_ChildPropBase, ChildText, ChildTag,
ChildFlag)):
for key in obj.get_tag_map():
if key in child_map:
raise TypeError("ambiguous Child properties: {} and {}"
" both use the same tag".format(
child_map[key],
obj))
child_map[key] = obj
child_props.add(obj)
elif isinstance(obj, Collector):
if collector_property is not None:
raise TypeError("multiple Collector properties on XSO "
"class")
collector_property = obj
namespace["TEXT_PROPERTY"] = text_property
namespace["CHILD_MAP"] = child_map
namespace["CHILD_PROPS"] = child_props
namespace["ATTR_MAP"] = attr_map
namespace["COLLECTOR_PROPERTY"] = collector_property
try:
tag = namespace["TAG"]
except KeyError:
tag = None
else:
try:
namespace["TAG"] = tag = normalize_tag(tag)
except ValueError:
raise TypeError("TAG attribute has incorrect format")
if (tag is not None and
"DECLARE_NS" not in namespace and
not any(hasattr(base, "DECLARE_NS") for base in bases)):
if tag[0] is None:
namespace["DECLARE_NS"] = {}
else:
namespace["DECLARE_NS"] = {
None: tag[0]
}
if protect:
namespace.setdefault("__slots__", ())
return super().__new__(mcls, name, bases, namespace)
def __init__(cls, name, bases, namespace, protect=True):
super().__init__(name, bases, namespace)
def __setattr__(cls, name, value):
try:
existing = getattr(cls, name).xq_descriptor
except AttributeError:
pass
else:
if isinstance(existing, _PropBase):
raise AttributeError("cannot rebind XSO descriptors")
if isinstance(value, _PropBase) and cls.__subclasses__():
raise TypeError("adding descriptors is forbidden on classes with"
" subclasses (subclasses: {})".format(
", ".join(map(str, cls.__subclasses__()))
))
if isinstance(value, Attr):
if value.tag in cls.ATTR_MAP:
raise TypeError("ambiguous Attr properties")
cls.ATTR_MAP[value.tag] = value
elif isinstance(value, Text):
if cls.TEXT_PROPERTY is not None:
raise TypeError("multiple Text properties on XSO class")
super().__setattr__("TEXT_PROPERTY", value)
elif isinstance(value, (_ChildPropBase, ChildText, ChildTag,
ChildFlag)):
updates = {}
for key in value.get_tag_map():
if key in cls.CHILD_MAP:
raise TypeError("ambiguous Child properties: {} and {} "
"both use the same tag".format(
cls.CHILD_MAP[key],
value))
updates[key] = value
cls.CHILD_MAP.update(updates)
cls.CHILD_PROPS.add(value)
elif isinstance(value, Collector):
if cls.COLLECTOR_PROPERTY is not None:
raise TypeError("multiple Collector properties on XSO class")
super().__setattr__("COLLECTOR_PROPERTY", value)
super().__setattr__(name, value)
def __delattr__(cls, name):
try:
existing = getattr(cls, name).xq_descriptor
except AttributeError:
pass
else:
if isinstance(existing, _PropBase):
raise AttributeError("cannot unbind XSO descriptors")
super().__delattr__(name)
def __prepare__(name, bases, **kwargs):
return collections.OrderedDict()
def parse_events(cls, ev_args, parent_ctx):
"""
Create an instance of this class, using the events sent into this
function. `ev_args` must be the event arguments of the ``"start"``
event.
.. seealso::
You probably should not call this method directly, but instead use
:class:`XSOParser` with a :class:`SAXDriver`.
.. note::
While this method creates an instance of the class, ``__init__`` is
not called. See the documentation of :meth:`.xso.XSO` for details.
This method is suspendable.
"""
with parent_ctx as ctx:
obj = cls.__new__(cls)
attrs = ev_args[2]
attr_map = cls.ATTR_MAP.copy()
for key, value in attrs.items():
try:
prop = attr_map.pop(key)
except KeyError:
if cls.UNKNOWN_ATTR_POLICY == UnknownAttrPolicy.DROP:
continue
else:
raise ValueError(
"unexpected attribute {!r} on {}".format(
key,
tag_to_str((ev_args[0], ev_args[1]))
)) from None
try:
if not prop.from_value(obj, value):
# assignment failed due to recoverable error, treat as
# absent
attr_map[key] = prop
except Exception:
prop.mark_incomplete(obj)
_mark_attributes_incomplete(attr_map.values(), obj)
logger.debug("while parsing XSO %s (%r)", cls,
value,
exc_info=True)
# true means suppress
if not obj.xso_error_handler(
prop,
value,
sys.exc_info()):
raise
for key, prop in attr_map.items():
try:
prop.handle_missing(obj, ctx)
except Exception:
logger.debug("while parsing XSO %s", cls,
exc_info=True)
# true means suppress
if not obj.xso_error_handler(
prop,
None,
sys.exc_info()):
raise
try:
prop = cls.ATTR_MAP[namespaces.xml, "lang"]
except KeyError:
pass
else:
lang = prop.__get__(obj, cls)
if lang is not None:
ctx.lang = lang
collected_text = []
while True:
ev_type, *ev_args = yield
if ev_type == "end":
break
elif ev_type == "text":
if not cls.TEXT_PROPERTY:
if ev_args[0].strip():
# true means suppress
if not obj.xso_error_handler(
None,
ev_args[0],
None):
raise ValueError("unexpected text")
else:
collected_text.append(ev_args[0])
elif ev_type == "start":
try:
handler = cls.CHILD_MAP[ev_args[0], ev_args[1]]
except KeyError:
if cls.COLLECTOR_PROPERTY:
handler = cls.COLLECTOR_PROPERTY.xq_descriptor
else:
yield from enforce_unknown_child_policy(
cls.UNKNOWN_CHILD_POLICY,
ev_args,
obj.xso_error_handler)
continue
try:
yield from guard(
handler.from_events(obj, ev_args, ctx),
ev_args
)
except Exception:
logger.debug("while parsing XSO %s", type(obj),
exc_info=True)
# true means suppress
if not obj.xso_error_handler(
handler,
ev_args,
sys.exc_info()):
raise
if collected_text:
collected_text = "".join(collected_text)
try:
cls.TEXT_PROPERTY.xq_descriptor.from_value(
obj,
collected_text
)
except Exception:
logger.debug("while parsing XSO", exc_info=True)
# true means suppress
if not obj.xso_error_handler(
cls.TEXT_PROPERTY.xq_descriptor,
collected_text,
sys.exc_info()):
raise
obj.validate()
obj.xso_after_load()
return obj
def register_child(cls, prop, child_cls):
"""
Register a new :class:`XMLStreamClass` instance `child_cls` for a given
:class:`Child` descriptor `prop`.
.. warning::
This method cannot be used after a class has been derived from this
class. This is for consistency: the method modifies the bookkeeping
attributes of the class. There would be two ways to deal with the
situation:
1. Updating all the attributes at all the subclasses and re-evaluate
the constraints of inheritance. This is simply not implemented,
although it would be the preferred way.
2. Only update the bookkeeping attributes on *this* class, hiding
the change from any existing subclasses. New subclasses would
pick the change up, however, which is inconsistent. This is the
way which was previously documented here and is not supported
anymore.
Obviously, (2) is bad, which is why it is not supported anymore. (1)
might be supported at some point in the future.
Attempting to use :meth:`register_child` on a class which already
has subclasses results in a :class:`TypeError`.
Note that *first* using :meth:`register_child` and only *then* deriving
clasess is a valid use: it will still lead to a consistent inheritance
hierarchy and is a convenient way to break reference cycles (e.g. if an
XSO may be its own child).
"""
if cls.__subclasses__():
raise TypeError(
"register_child is forbidden on classes with subclasses"
" (subclasses: {})".format(
", ".join(map(str, cls.__subclasses__()))
))
if child_cls.TAG in cls.CHILD_MAP:
raise ValueError("ambiguous Child")
prop.xq_descriptor._register(child_cls)
cls.CHILD_MAP[child_cls.TAG] = prop.xq_descriptor
# I know it makes only partially sense to have a separate metasubclass for
# this, but I like how :meth:`parse_events` is *not* accessible from
# instances.
class CapturingXMLStreamClass(XMLStreamClass):
"""
This is a subclass of :meth:`XMLStreamClass`. It overrides the
:meth:`parse_events` to capture the incoming events, including the initial
event.
.. see::
:class:`CapturingXSO`
.. automethod:: parse_events
"""
def parse_events(cls, ev_args, parent_ctx):
"""
Capture the events sent to :meth:`.XSO.parse_events`,
including the initial `ev_args` to a list and call
:meth:`_set_captured_events` on the result of
:meth:`.XSO.parse_events`.
Like the method it overrides, :meth:`parse_events` is suspendable.
"""
dest = [("start", )+tuple(ev_args)]
result = yield from capture_events(
super().parse_events(ev_args, parent_ctx),
dest
)
result._set_captured_events(dest)
return result
class XSO(metaclass=XMLStreamClass):
"""
XSO is short for **X**\\ ML **S**\\ tream **O**\\ bject and means an object
which represents a subtree of an XML stream. These objects can also be
created and validated on-the-fly from SAX-like events using
:class:`XSOParser`.
The constructor does not require any arguments and forwards them directly
the next class in the resolution order. Note that during deserialization,
``__init__`` is not called. It is assumed that all data is loaded from the
XML stream and thus no initialization is required.
This is beneficial to applications, as it allows them to define mandatory
arguments for ``__init__``. This would not be possible if ``__init__`` was
called during deserialization. A way to execute code after successful
deserialization is provided through :meth:`xso_after_load`.
:class:`XSO` objects support copying. Like with deserialisation,
``__init__`` is not called during copy. The default implementation only
copies the XSO descriptors’ values (with deepcopy, they are copied
deeply). If you have more attributes to copy, you need to override
``__copy__`` and ``__deepcopy__`` methods.
.. versionchanged:: 0.4
Copy and deepcopy support has been added. Previously, copy copied not
enough data, while deepcopy copied too much data (including descriptor
objects).
To declare an XSO, inherit from :class:`XSO` and provide
the following attributes on your class:
* A ``TAG`` attribute, which is a tuple ``(namespace_uri, localname)``
representing the tag of the XML element you want to match.
* An arbitrary number of :class:`Text`, :class:`Collector`, :class:`Child`,
:class:`ChildList` and :class:`Attr`-based attributes.
.. seealso::
:class:`.xso.model.XMLStreamClass`
is the metaclass of :class:`XSO`. The documentation of the metaclass
holds valuable information with respect to modifying :class:`XSO`
*classes* and subclassing.
.. note::
Attributes whose name starts with ``xso_`` or ``_xso_`` are reserved for
use by the :mod:`aioxmpp.xso` implementation. Do not use these in your
code if you can possibly avoid it.
:class:`XSO` subclasses automatically declare a
:attr:`~.xso.model.XMLStreamClass.__slots__` attribute which does not
include the ``__dict__`` value. This effectively prevents any attributes
not declared on the class as descriptors from being written. The rationale
is detailed on in the linked documentation. To prevent this from happening
in your subclass, inherit with `protect` set to false::
class MyXSO(xso.XSO, protect=False):
pass
.. versionadded:: 0.6
The handling of the :attr:`~.xso.model.XMLStreamClass.__slots__`
attribute was added.
To further influence the parsing behaviour of a class, two attributes are
provided which give policies for unexpected elements in the XML tree:
.. attribute:: UNKNOWN_CHILD_POLICY
:annotation: = UnknownChildPolicy.DROP
A value from the :class:`UnknownChildPolicy` enum which defines the
behaviour if a child is encountered for which no matching attribute is
found.
Note that this policy has no effect if a :class:`Collector` descriptor
is present, as it takes all children for which no other descriptor
exists, thus all children are known.
.. attribute:: UNKNOWN_ATTR_POLICY
:annotation: = UnknownAttrPolicy.DROP
A value from the :class:`UnknownAttrPolicy` enum which defines the
behaviour if an attribute is encountered for which no matching
descriptor is found.
Example::
class Body(aioxmpp.xso.XSO):
TAG = ("jabber:client", "body")
text = aioxmpp.xso.Text()
class Message(aioxmpp.xso.XSO):
TAG = ("jabber:client", "message")
UNKNOWN_CHILD_POLICY = aioxmpp.xso.UnknownChildPolicy.DROP
type_ = aioxmpp.xso.Attr(tag="type")
from_ = aioxmpp.xso.Attr(tag="from")
to = aioxmpp.xso.Attr(tag="to")
id_ = aioxmpp.xso.Attr(tag="id")
body = aioxmpp.xso.Child([Body])
Beyond the validation of the individual descriptor values, it is possible
to implement more complex validation steps by overriding the
:meth:`validate` method:
.. automethod:: validate
The following methods are available on instances of :class:`XSO`:
.. automethod:: xso_serialise_to_sax
The following **class methods** are provided by the metaclass:
.. automethod:: parse_events(ev_args)
.. automethod:: register_child(prop, child_cls)
To customize behaviour of deserialization, these methods are provided which
can be re-implemented by subclasses:
.. automethod:: xso_after_load
.. automethod:: xso_error_handler
"""
UNKNOWN_CHILD_POLICY = UnknownChildPolicy.DROP
UNKNOWN_ATTR_POLICY = UnknownAttrPolicy.DROP
__slots__ = ("_xso_contents", "__weakref__")
def __new__(cls, *args, **kwargs):
# XXX: is it always correct to omit the arguments here?
# the semantics of the __new__ arguments are odd to say the least
result = super().__new__(cls)
result._xso_contents = dict()
return result
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __copy__(self):
result = type(self).__new__(type(self))
result._xso_contents.update(self._xso_contents)
return result
def __deepcopy__(self, memo):
result = type(self).__new__(type(self))
result._xso_contents = {
k: copy.deepcopy(v, memo)
for k, v in self._xso_contents.items()
}
return result
def validate(self):
"""
Validate the objects structure beyond the values of individual fields
(which have their own validators).
This first calls :meth:`_PropBase.validate_contents` recursively on the
values of all child descriptors. These may raise (or re-raise) errors
which occur during validation of the child elements.
To implement your own validation logic in a subclass of :class:`XSO`,
override this method and call it via :func:`super` before doing your
own validation.
Validate is called by the parsing stack after an object has been fully
deserialized from the SAX event stream. If the deserialization fails
due to invalid values of descriptors or due to validation failures in
child objects, this method is obviously not called.
"""
for descriptor in self.CHILD_PROPS:
descriptor.validate_contents(self)
def xso_after_load(self):
"""
After an object has been successfully deserialized, this method is
called. Note that ``__init__`` is never called on objects during
deserialization.
"""
def xso_error_handler(self, descriptor, ev_args, exc_info):
"""
This method is called whenever an error occurs while parsing.
If an exception is raised by the parsing function of a descriptor
attribute, such as :class:`Attr`, the `descriptor` is passed as first
argument, the `exc_info` tuple as third argument and the arguments
which led to the descriptor being invoked as second argument.
If an unknown child is encountered and the :attr:`UNKNOWN_CHILD_POLICY`
is set to :attr:`UnknownChildPolicy.FAIL`, `descriptor` and `exc_info`
are passed as :data:`None` and `ev_args` are the arguments to the
``"start"`` event of the child (i.e. a triple
``(namespace_uri, localname, attributes)``).
If the error handler wishes to suppress the exception, it must return a
true value. Otherwise, the exception is propagated (or a new exception
is raised, if the error was not caused by an exception). The error
handler may also raise its own exception.
.. warning::
Suppressing exceptions can cause invalid input to reside in the
object or the object in general being in a state which violates the
schema.
For example, suppressing exceptions about missing attributes will
cause the attribute to remain uninitialized (i.e. left at its
:attr:`default` value).
Even if the error handler suppresses an exception caused by a broken
child, that child will not be added to the object.
"""
def xso_serialise_to_sax(self, dest):
"""
Serialise the XSO to a SAX handler.
:param dest: SAX handler to send the events to
.. versionchanged:: 0.11
The method was renamed from unparse_to_sax to
xso_serialise_to_sax.
"""
# XXX: if anyone has an idea on how to optimize this, this is a hotspot
# when serialising XML
# things which do not suffice or even change anything:
# 1. pull things in local variables
# 2. get rid of the try/finally, even without any replacement
cls = type(self)
attrib = {}
for prop in cls.ATTR_MAP.values():
prop.to_dict(self, attrib)
if cls.DECLARE_NS:
for prefix, uri in cls.DECLARE_NS.items():
dest.startPrefixMapping(prefix, uri)
dest.startElementNS(self.TAG, None, attrib)
try:
if cls.TEXT_PROPERTY:
cls.TEXT_PROPERTY.to_sax(self, dest)
for prop in cls.CHILD_PROPS:
prop.to_sax(self, dest)
if cls.COLLECTOR_PROPERTY:
cls.COLLECTOR_PROPERTY.to_sax(self, dest)
finally:
dest.endElementNS(self.TAG, None)
if cls.DECLARE_NS:
for prefix, uri in cls.DECLARE_NS.items():
dest.endPrefixMapping(prefix)
def unparse_to_node(self, parent):
handler = lxml.sax.ElementTreeContentHandler(
makeelement=parent.makeelement)
handler.startDocument()
handler.startElementNS((None, "root"), None)
self.xso_serialise_to_sax(handler)
handler.endElementNS((None, "root"), None)
handler.endDocument()
parent.extend(handler.etree.getroot())
class CapturingXSO(XSO, metaclass=CapturingXMLStreamClass):
"""
The following **class methods** is provided by the metaclass (which is not
publicly available, but a subclass of :class:`~.XMLStreamClass`):
.. automethod:: parse_events
The :meth:`_set_captured_events` method can be overridden by subclasses to
make use of the captured events:
.. automethod:: _set_captured_events
An example use case for this class is :class:`.disco.InfoQuery`, combined
with :mod:`aioxmpp.entitycaps`. We want to be able to store hashes and the
generating XML data for use with future versions, including XML data which
cannot be parsed by an XSO in the current process (for example, due to an
unknown namespace or a plugin which is available but not loaded). With the
captured events, it is possible to re-create XML semantically equivalent to
the XML originally received.
.. versionadded:: 0.5
"""
@abc.abstractmethod
def _set_captured_events(self, events):
"""
This method is called by :meth:`parse_events` after parsing the
object. `events` is the list of event tuples which this object was
deserialised from.
Subclasses must override this method.
"""
class SAXDriver(xml.sax.handler.ContentHandler):
"""
This is a :class:`xml.sax.handler.ContentHandler` subclass which *only*
supports namespace-conforming SAX event sources.
`dest_generator_factory` must be a function which returns a new suspendable
method supporting the interface of :class:`XSOParser`. The SAX events are
converted to an internal event format and sent to the suspendable function
in order.
`on_emit` may be a callable. Whenever a suspendable function returned by
`dest_generator_factory` returns, with the return value as sole argument.
When you are done with a :class:`SAXDriver`, you should call :meth:`close`
to clean up internal parser state.
.. automethod:: close
"""
def __init__(self, dest_generator_factory, on_emit=None):
self._on_emit = on_emit
self._dest_factory = dest_generator_factory
self._dest = None
def _emit(self, value):
if self._on_emit:
self._on_emit(value)
def _send(self, value):
if self._dest is None:
self._dest = self._dest_factory()
self._dest.send(None)
try:
self._dest.send(value)
except StopIteration as err:
self._emit(err.value)
self._dest = None
except: # NOQA
self._dest = None
raise
def startElementNS(self, name, qname, attributes):
uri, localname = name
self._send(("start", uri, localname, dict(attributes)))
def characters(self, data):
self._send(("text", data))
def endElementNS(self, name, qname):
self._send(("end",))
def close(self):
"""
Clean up all internal state.
"""
if self._dest is not None:
self._dest.close()
self._dest = None
class Context:
def __init__(self):
super().__init__()
self.lang = None
def __enter__(self):
new_ctx = Context()
new_ctx.__dict__ = self.__dict__.copy()
return new_ctx
def __exit__(self, *args):
pass
class XSOParser:
"""
A generic XSO parser which supports a dynamic set of XSOs to
parse. :class:`XSOParser` objects are callable and they are suspendable
methods (i.e. calling a :class:`XSOParser` returns a generator which parses
stanzas from sax-ish events. Use with :class:`SAXDriver`).
Example use::
# let Message be a XSO class, like in the XSO example
result = None
def catch_result(value):
global result
result = value
parser = aioxmpp.xso.XSOParser()
parser.add_class(Message, catch_result)
sd = aioxmpp.xso.SAXDriver(parser)
lxml.sax.saxify(lxml.etree.fromstring(
""
), sd)
The following methods can be used to dynamically add and remove top-level
:class:`XSO` classes.
.. automethod:: add_class
.. automethod:: remove_class
.. automethod:: get_tag_map
"""
def __init__(self):
self._class_map = {}
self._tag_map = {}
self._ctx = Context()
@property
def lang(self):
return self._ctx.lang
@lang.setter
def lang(self, value):
self._ctx.lang = value
def add_class(self, cls, callback):
"""
Add a class `cls` for parsing as root level element. When an object of
`cls` type has been completely parsed, `callback` is called with the
object as argument.
"""
if cls.TAG in self._tag_map:
raise ValueError(
"duplicate tag: {!r} is already handled by {}".format(
cls.TAG,
self._tag_map[cls.TAG]))
self._class_map[cls] = callback
self._tag_map[cls.TAG] = (cls, callback)
def get_tag_map(self):
"""
Return the internal mapping which maps tags to tuples of ``(cls,
callback)``.
.. warning::
The results of modifying this dict are undefined. Make a copy if you
need to modify the result of this function.
"""
return self._tag_map
def get_class_map(self):
"""
Return the internal mapping which maps classes to the associated
callbacks.
.. warning::
The results of modifying this dict are undefined. Make a copy if you
need to modify the result of this function.
"""
return self._class_map
def remove_class(self, cls):
"""
Remove a XSO class `cls` from parsing. This method raises
:class:`KeyError` with the classes :attr:`TAG` attribute as argument if
removing fails because the class is not registered.
"""
del self._tag_map[cls.TAG]
del self._class_map[cls]
def __call__(self):
while True:
ev_type, *ev_args = yield
if ev_type == "text" and not ev_args[0].strip():
continue
tag = ev_args[0], ev_args[1]
try:
cls, cb = self._tag_map[tag]
except KeyError:
raise UnknownTopLevelTag(
"unhandled top-level element",
ev_args)
cb((yield from cls.parse_events(ev_args, self._ctx)))
def drop_handler(ev_args):
depth = 1
while depth:
ev = yield
if ev[0] == "start":
depth += 1
elif ev[0] == "end":
depth -= 1
def enforce_unknown_child_policy(policy, ev_args, error_handler=None):
if policy == UnknownChildPolicy.DROP:
yield from drop_handler(ev_args)
else:
if error_handler is not None:
if error_handler(None, ev_args, None):
yield from drop_handler(ev_args)
return
raise ValueError("unexpected child")
def guard(dest, ev_args):
depth = 1
try:
next(dest)
while True:
ev = yield
if ev[0] == "start":
depth += 1
elif ev[0] == "end":
depth -= 1
try:
dest.send(ev)
except StopIteration as exc:
return exc.value
finally:
while depth > 0:
ev_type, *_ = yield
if ev_type == "end":
depth -= 1
elif ev_type == "start":
depth += 1
def lang_attr(instance, ctx):
"""
A `missing` handler for :class:`Attr` descriptors. If any parent object has
a ``xml:lang`` attribute set, its value is used.
Pass as `missing` argument to :class:`Attr` constructors to use this
behaviour for a given attribute.
"""
return ctx.lang
def capture_events(receiver, dest):
"""
Capture all events sent to `receiver` in the sequence `dest`. This is a
generator, and it is best used with ``yield from``. The observable effect
of using this generator with ``yield from`` is identical to the effect of
using `receiver` with ``yield from`` directly (including the return value),
but in addition, the values which are *sent* to the receiver are captured
in `dest`.
If `receiver` raises an exception or the generator is closed prematurely
using its :meth:`close`, `dest` is cleared.
This is used to implement :class:`CapturingXSO`. See the documentation
there for use cases.
.. versionadded:: 0.5
"""
# the following code is a copy of the formal definition of `yield from`
# in PEP 380, with modifications to capture the value sent during yield
_i = iter(receiver)
try:
_y = next(_i)
except StopIteration as _e:
return _e.value
try:
while True:
try:
_s = yield _y
except GeneratorExit as _e:
try:
_m = _i.close
except AttributeError:
pass
else:
_m()
raise _e
except BaseException as _e:
_x = sys.exc_info()
try:
_m = _i.throw
except AttributeError:
raise _e
else:
try:
_y = _m(*_x)
except StopIteration as _e:
_r = _e.value
break
else:
dest.append(_s)
try:
if _s is None:
_y = next(_i)
else:
_y = _i.send(_s)
except StopIteration as _e:
_r = _e.value
break
except: # NOQA
dest.clear()
raise
return _r
def events_to_sax(events, dest):
"""
Convert an iterable `events` of XSO events to SAX events by calling the
matching SAX methods on `dest`
"""
name_stack = []
for ev_type, *ev_args in events:
if ev_type == "start":
name = (ev_args[0], ev_args[1])
dest.startElementNS(name, None, ev_args[2])
name_stack.append(name)
elif ev_type == "end":
name = name_stack.pop()
dest.endElementNS(name, None)
elif ev_type == "text":
dest.characters(ev_args[0])
class _CollectorContentHandlerFilter(xml.sax.handler.ContentHandler):
def __init__(self, receiver):
super().__init__()
self.__receiver = receiver
def setDocumentLocator(self, locator):
self.__receiver.setDocumentLocator(locator)
def startElement(self, name, attrs):
self.__receiver.startElement(name, attrs)
def endElement(self, name):
self.__receiver.endElement(name)
def startElementNS(self, name, qname, attrs):
self.__receiver.startElementNS(name, qname, attrs)
def endElementNS(self, name, qname):
self.__receiver.endElementNS(name, qname)
def characters(self, content):
self.__receiver.characters(content)
def ignorableWhitespace(self, content):
self.__receiver.ignorableWhitespace(content)
def processingInstruction(self, target, data):
self.__receiver.processingInstruction(target, data)
def skippedEntity(self, name):
self.__receiver.skippedEntity(name)
class XSOEnumMixin:
"""
Mix-in to create enumerations of XSOs.
.. versionadded:: 0.10
The enumeration member values must be pairs of ``namespace``, ``localpart``
strings. Each enumeration member is equipped with an :attr:`xso_class`
attribute at definition time.
.. automethod:: to_xso
.. autoattribute:: enum_member
.. attribute:: xso_class
A :class:`aioxmpp.xso.XSO` *subclass* which has the enumeration members
value as :attr:`~.XSO.TAG`. So the subclass matches elements which have
the qualified tag in the enumeration member value.
The class does not have any XSO descriptors assigned. They can be added
after class definition.
.. attribute:: enum_member
The enumeration member to which the :attr:`xso_class` belongs.
This allows to use XSOs and enumeration members more
interchangeably; see :attr:`enum_member` for details.
.. method:: to_xso
Return the XSO itself.
This allows to use XSOs and enumeration members more
interchangeably; see :meth:`to_xso` for details.
Example usage::
class TagEnum(aioxmpp.xso.XSOEnumMixin, enum.Enum):
X = ("uri:foo", "x")
Y = ("uri:foo", "y")
TagEnum.X.xso_class.enabled = aioxmpp.xso.Attr(
"enabled",
type_=aioxmpp.xso.Bool()
)
The :class:`TagEnum` members then have a :attr:`xso_class` attribute which
is a *subclass* of :class:`~aioxmpp.xso.XSO` (**not** an instance of a
subclass of :class:`~aioxmpp.xso.XSO`).
The :attr:`xso_class` for :attr:`TagEnum.X` also supports the ``enabled``
attribute (due to it being monkey-patched onto it), while the
:attr:`xso_class` for :attr:`TagEnum.Y` does not. Thus, monkey-patching
can be used to customize the individual XSO classes of the members.
To use such an enum on a descriptor, the following syntax can be used::
class Element(aioxmpp.xso.XSO):
TAG = ("uri:foo", "parent")
child = aioxmpp.xso.Child([
member.xso_class
for member in TagEnum
])
"""
def __init__(self, namespace, localname):
super().__init__()
self.xso_class = self._create_class()
def _create_name(self):
return "".join(map(str.title, self.name.split("_")))
def _create_class(self):
def to_xso(self):
return self
return XMLStreamClass(
self._create_name(),
(XSO,),
{
"TAG": self.value,
"__qualname__": "{}.{}.xso_class".format(
type(self).__qualname__,
self.name,
),
"enum_member": self,
"to_xso": to_xso,
},
)
@property
def enum_member(self):
"""
The object (enum member) itself.
This property exists to make it easier to use the XSO objects and the
enumeration members interchangeably. The XSO objects also have the
:attr:`enum_member` property to obtain the enumeration member to which
they belong. Code which is only interested in the enumeration member
can thus access the :attr:`enum_member` attribute to "coerce" both
(enumeration members and instances of their XSO classes) into
enumeration members.
"""
return self
def to_xso(self):
"""
A new instance of the :attr:`xso_class`.
This method exists to make it easier to use the XSO objects and the
enumeration members interchangeably. The XSO objects also have the
:meth:`to_xso` method which just returns the XSO unmodified.
Code which needs an XSO, but does not care about the data, can thus use
the :meth:`to_xso` method to "coerce" either (enumeration members and
instances of their XSO classes) into XSOs.
"""
return self.xso_class()
class AbstractTextChild(XSO):
"""
One of the recurring patterns when using :mod:`xso` is the use of a XSO
subclass to represent an XML node which has only character data and an
``xml:lang`` attribute.
The `text` and `lang` arguments to the constructor can be used to
initialize the attributes.
This class provides exactly that. It inherits from :class:`XSO`.
.. attribute:: lang
The ``xml:lang`` of the node, as :class:`~.structs.LanguageTag`.
.. attribute:: text
The textual content of the node (XML character data).
Example use as base class::
class Subject(xso.AbstractTextChild):
TAG = (namespaces.client, "subject")
The full example can also be found in the source code of
:class:`.stanza.Subject`.
"""
lang = LangAttr()
text = Text(default=None)
def __init__(self, text=None, lang=None):
super().__init__()
self.text = text
self.lang = lang
def __eq__(self, other):
try:
other_key = (other.lang, other.text)
except AttributeError:
return NotImplemented
return (self.lang, self.text) == other_key
aioxmpp/xso/query.py 0000664 0000000 0000000 00000027053 14160146213 0015070 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: query.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
# .
#
########################################################################
import abc
import copy
import itertools
import inspect
import operator
class _SoftExprMixin:
"""
This mixin is used for metaclasses and descriptors.
It defines the operators ``/`` and ``[]``, which are rarely used for either
classes or descriptors.
.. seealso::
:class:`_ExprMixin`
which inherits from this class and defines more operators, some of
which would be unsafe to implement on classes or descriptors, such as
``==``.
"""
def __truediv__(self, other):
if isinstance(other, PreExpr):
return as_expr(other, lhs=self)
elif isinstance(other, Expr):
return as_expr(other, lhs=self)
return NotImplemented
def __getitem__(self, index):
if isinstance(index, where):
return ExprFilter(self, as_expr(index.expr))
return Nth(self, as_expr(index))
class _ExprMixin(_SoftExprMixin):
"""
This mixin defines operators which are only "safe" to overload in
constrained situations. These operators often have meanings and may be
implicitly used by the python language; thus, they are only defined on
:class:`Expr` subclasses and some :class:`PreExpr` subclasses.
The defined operators currently are:
* Comparison: ``==``, ``<``, ``<=``, ``>=``, ``>``, ``!=``
"""
def __eq__(self, other):
return CmpOp(
as_expr(self),
as_expr(other),
operator.eq,
)
def __ne__(self, other):
return CmpOp(
as_expr(self),
as_expr(other),
operator.ne,
)
def __lt__(self, other):
return CmpOp(
as_expr(self),
as_expr(other),
operator.lt,
)
def __gt__(self, other):
return CmpOp(
as_expr(self),
as_expr(other),
operator.gt,
)
def __ge__(self, other):
return CmpOp(
as_expr(self),
as_expr(other),
operator.ge,
)
def __le__(self, other):
return CmpOp(
as_expr(self),
as_expr(other),
operator.le,
)
class EvaluationContext:
"""
The evaluation context holds contextual information for the evaluation of a
query expression.
Most notably, it provides the methods for acquiring and replacing the
toplevel objects of classes:
.. automethod:: get_toplevel_object()
.. automethod:: set_toplevel_object()
In addition, it provides shortcuts for evaluating expressions:
.. automethod:: eval
.. automethod:: eval_bool
"""
def __init__(self, *args, **kwargs):
super().__init__()
self._toplevels = {}
def __copy__(self):
result = type(self).__new__(type(self))
result._toplevels = dict(self._toplevels)
return result
def get_toplevel_object(self, class_):
"""
Return the toplevel object for the given `class_`. Only exact matches
are returned.
"""
return self._toplevels[class_]
def set_toplevel_object(self, instance, class_=None):
"""
Set the toplevel object to return from :meth:`get_toplevel_object` when
asked for `class_` to `instance`.
If `class_` is :data:`None`, the :func:`type` of the `instance` is
used.
"""
if class_ is None:
class_ = type(instance)
self._toplevels[class_] = instance
def eval(self, expr):
"""
Evaluate the expression `expr` and return the result.
The result of an expression is always an iterable.
"""
return expr.eval(self)
def eval_bool(self, expr):
"""
Evaluate the expression `expr` and return the truthness of its result.
A result of an expression is said to be true if it contains at least
one value. It has the same semantics as :func:`bool` on sequences.s
"""
result = expr.eval(self)
iterator = iter(result)
try:
next(iterator)
except StopIteration:
return False
else:
return True
finally:
if hasattr(iterator, "close"):
iterator.close()
class Expr(_ExprMixin, metaclass=abc.ABCMeta):
"""
Base class for things which are solely expressions and nothing else.
"""
@abc.abstractmethod
def eval(self, ec):
pass
def eval_leaf(self, ec):
result = self.eval(ec)
if inspect.isgenerator(result):
return list(result)
return result
def __repr__(self):
return "<{}.{} {!r}>".format(
type(self).__module__,
type(self).__qualname__,
self.__dict__,
)
class ContextInstance(Expr):
def __init__(self, class_, **kwargs):
super().__init__(**kwargs)
self.class_ = class_
def eval(self, ec):
"""
Retrieve the current toplevel instance of `class_` from the
:class:`EvaluationContext`. `
"""
try:
return [ec.get_toplevel_object(self.class_)]
except KeyError:
return []
class GetDescriptor(Expr):
"""
Represents a descriptor bound to a class.
As an expression, it represents the query for all values of the
`descriptor` on an all instances of `class_` in the result set of `expr`.
"""
def __init__(self, expr, descriptor):
super().__init__()
self.expr = expr
self.descriptor = descriptor
def new_values(self):
return []
def update_values(self, v, vnew):
v.append(vnew)
def eval(self, ec):
vs = self.new_values()
for instance in self.expr.eval(ec):
try:
vnew = self.descriptor.__get__(instance, type(instance))
except AttributeError:
continue
self.update_values(
vs,
vnew
)
return vs
class GetMappingDescriptor(GetDescriptor):
def __init__(self, expr, descriptor, mapping_factory=dict, **kwargs):
super().__init__(expr, descriptor, **kwargs)
self.mapping_factory = mapping_factory
def new_values(self):
return self.mapping_factory()
def update_values(self, v, vnew):
v.update(vnew)
class GetSequenceDescriptor(GetDescriptor):
def __init__(self, expr, descriptor, sequence_factory=list, **kwargs):
super().__init__(expr, descriptor, **kwargs)
self.sequence_factory = sequence_factory
def new_values(self):
return self.sequence_factory()
def update_values(self, v, vnew):
v.extend(vnew)
class GetInstances(Expr):
def __init__(self, expr, class_):
super().__init__()
self.expr = expr
self.class_ = class_
def eval(self, ec):
for obj in self.expr.eval(ec):
if isinstance(obj, self.class_):
yield obj
class Nth(Expr):
def __init__(self, expr, nth_expr):
super().__init__()
self.expr = expr
self.nth_expr = nth_expr
def eval(self, ec):
n, = self.nth_expr.eval(ec)
iterable = self.expr.eval(ec)
if isinstance(n, slice):
return itertools.islice(
iterable,
n.start, n.stop, n.step,
)
return itertools.islice(
self.expr.eval(ec),
n, n+1,
)
class ExprFilter(Expr):
def __init__(self, expr, filter_expr):
super().__init__()
self.expr = expr
self.filter_expr = filter_expr
def eval(self, ec):
for value in self.expr.eval(ec):
sub_ec = copy.copy(ec)
sub_ec.set_toplevel_object(value)
filter_result = sub_ec.eval_bool(self.filter_expr)
if filter_result:
yield value
class where:
"""
Wrap the expression `expr` so that it can be used as a filter in ``[]``.
"""
def __init__(self, expr):
self.expr = expr
class _BoolOpMixin:
def eval(self, ec):
if self.eval_leaf(ec):
yield True
class CmpOp(_BoolOpMixin, Expr):
def __init__(self, operand1, operand2, operator):
super().__init__()
self.operand1 = operand1
self.operand2 = operand2
self.operator = operator
def eval_leaf(self, ec):
vs1 = self.operand1.eval_leaf(ec)
vs2 = self.operand2.eval_leaf(ec)
for v1 in vs1:
for v2 in vs2:
if self.operator(v1, v2):
return True
return False
class NotOp(_BoolOpMixin, Expr):
def __init__(self, operand):
super().__init__()
self.operand = operand
def eval_leaf(self, ec):
return not ec.eval_bool(self.operand)
def not_(expr):
"""
Return the boolean-not of the value of `expr`. A expression value is true
if it contains at least one element and false otherwise.
.. seealso::
:meth:`EvaluationContext.eval_bool`
which is used behind the scenes to calculate the boolean value of
`expr`.
:class:`NotOp`
which actually implements the operator.
"""
return NotOp(as_expr(expr))
class Constant(Expr):
def __init__(self, value):
super().__init__()
self.value = value
def eval(self, ec):
return [self.value]
# Here be dragons: if you use metaclass=abc.ABCMeta with this class, very
# interesting things will blow up
class PreExpr(_SoftExprMixin):
@abc.abstractmethod
def xq_instantiate(self, expr=None):
pass
class Class(PreExpr):
def xq_instantiate(self, expr=None):
if expr is None:
return ContextInstance(self)
return GetInstances(expr, self)
class BoundDescriptor(_ExprMixin, PreExpr):
def __init__(self, class_, descriptor, expr_class, expr_kwargs={},
**kwargs):
super().__init__(**kwargs)
self.xq_xso_class = class_
self.xq_descriptor = descriptor
self.xq_expr_class = expr_class
self.xq_expr_kwargs = expr_kwargs
def xq_instantiate(self, expr=None):
return self.xq_expr_class(
self.xq_xso_class.xq_instantiate(expr),
self.xq_descriptor,
**self.xq_expr_kwargs
)
def __getattr__(self, name):
try:
return super().__getattr__(name)
except AttributeError:
if not name.startswith("xq_"):
return getattr(self.xq_descriptor, name)
raise
def as_expr(thing, lhs=None):
if isinstance(thing, Expr):
if hasattr(thing, "expr"):
thing.expr = as_expr(thing.expr, lhs=lhs)
return thing
if isinstance(thing, PreExpr):
return thing.xq_instantiate(lhs)
return Constant(thing)
aioxmpp/xso/types.py 0000664 0000000 0000000 00000115111 14160146213 0015060 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: types.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
# .
#
########################################################################
"""
:mod:`aioxmpp.xso.types` --- Types specifications for use with :mod:`aioxmpp.xso.model`
#######################################################################################
See :mod:`aioxmpp.xso` for documentation.
""" # NOQA: E501
import abc
import array
import base64
import binascii
import decimal
import ipaddress
import json
import numbers
import re
import unicodedata
import warnings
import pytz
from datetime import datetime, timedelta, date, time
from .. import structs, i18n
class Unknown:
"""
A wrapper for an unknown enumeration value.
:param value: The raw value of the "enumeration" "member".
:type value: arbitrary
Instances of this class may be emitted from and accepted by
:class:`EnumCDataType` and :class:`EnumElementType`, see the documentation
there for details.
:class:`Unknown` instances compare equal when they hold an equal value.
:class:`Unknown` objects are hashable if their values are hashable. The
value they refer to cannot be changed during the lifetime of an
:class:`Unknown` object.
"""
def __init__(self, value):
super().__init__()
self.__value = value
@property
def value(self):
return self.__value
def __hash__(self):
return hash(self.__value)
def __eq__(self, other):
try:
return self.__value == other.__value
except AttributeError:
return NotImplemented
def __repr__(self):
return "".format(
self.__value
)
class AbstractCDataType(metaclass=abc.ABCMeta):
"""
Subclasses of this class describe character data types.
They are used to convert python values from (:meth:`parse`) and to
(:meth:`format`) XML character data as well as enforce basic type
restrictions (:meth:`coerce`) when values are assigned to descriptors
using this type.
This type can be used by the character data descriptors, like :class:`Attr`
and :class:`Text`.
.. automethod:: coerce
.. automethod:: parse
.. automethod:: format
"""
def coerce(self, v):
"""
Force the given value `v` to be of the type represented by this
:class:`AbstractCDataType`.
:meth:`coerce` is called when user code assigns values to descriptors
which use the type; it is notably not called when values are extracted
from SAX events, as these go through :meth:`parse` and that is expected
to return correctly typed values.
If `v` cannot be sensibly coerced, :class:`TypeError` is raised (in
some rare occasions, :class:`ValueError` may be ok too).
Return a coerced version of `v` or `v` itself if it matches the
required type.
.. note::
For the sake of usability, coercion should only take place rarely;
in most of the cases, throwing :class:`TypeError` is the preferred
method.
Otherwise, a user might be surprised why the :class:`int` they
assigned to an attribute suddenly became a :class:`str`.
"""
return v
@abc.abstractmethod
def parse(self, v):
"""
Convert the given string `v` into a value of the appropriate type this
class implements and return the result.
If conversion fails, :class:`ValueError` is raised.
The result of :meth:`parse` must pass through :meth:`coerce` unchanged.
"""
def format(self, v):
"""
Convert the value `v` of the type this class implements to a str.
This conversion does not fail.
The returned value can be passed to :meth:`parse` to obtain `v`.
"""
return str(v)
class AbstractElementType(metaclass=abc.ABCMeta):
"""
Subclasses of this class describe XML subtree types.
They are used to convert python values from (:meth:`unpack`) and to
(:meth:`pack`) XML subtrees represented as :class:`XSO` instances as well
as enforce basic type restrictions (:meth:`coerce`) when values are
assigned to descriptors using this type.
This type can be used by the element descriptors, like
:class:`ChildValueList` and :class:`ChildValueMap`.
.. automethod:: get_xso_types
.. automethod:: coerce
.. automethod:: unpack
.. automethod:: pack
"""
@abc.abstractmethod
def get_xso_types(self):
"""
Return the :class:`XSO` subclasses supported by this type.
:rtype: :class:`~collections.Iterable` of :class:`XMLStreamClass`
:return: The :class:`XSO` subclasses which can be passed to
:meth:`unpack`.
"""
@abc.abstractmethod
def unpack(self, obj):
"""
Convert a :class:`XSO` instance to another object, usually a scalar
value or a tuple.
:param obj: The object to unpack.
:type obj: One of the types returned by :meth:`get_xso_types`.
:raises ValueError: if the conversaion fails.
:return: The unpacked value.
Think of unpack like a high-level :func:`struct.unpack`: it converts
wire-format data (XML subtrees represented as :class:`XSO` instances)
to python values.
"""
@abc.abstractmethod
def pack(self, v):
"""
Convert the value `v` of the type this class implements to an
:class:`XSO` instance.
:param v: Value to pack
:type v: as returned by :meth:`unpack`
:rtype: One of the types returned by :meth:`get_xso_types`.
:return: The packed value.
The returned value can be passed through :meth:`unpack` to obtain a
value equal to `v`.
Think of pack like a high-level :func:`struct.pack`: it converts
python values to wire-format (XML subtrees represented as :class:`XSO`
instances).
"""
def coerce(self, v):
"""
Force the given value `v` to be compatible to :meth:`pack`.
:meth:`coerce` is called when user code assigns
values to descriptors which use the type; it is notably not called when
values are extracted from SAX events, as these go through
:meth:`unpack` and that is expected to return correctly typed values.
If `v` cannot be sensibly coerced, :class:`TypeError` is raised (in
some rare occasions, :class:`ValueError` may be ok too).
Return a coerced version of `v` or `v` itself if it matches the
required type.
.. note::
For the sake of usability, coercion should only take place rarely;
in most of the cases, throwing :class:`TypeError` is the preferred
method.
Otherwise, a user might be surprised why the :class:`int` they
assigned to an attribute suddenly became a :class:`str`.
"""
return v
class String(AbstractCDataType):
"""
String :term:`Character Data Type`, optionally with string preparation.
Optionally, a stringprep function `prepfunc` can be applied on the
string. A stringprep function must take the string and prepare it
accordingly; if it is invalid input, it must raise
:class:`ValueError`. Otherwise, it shall return the prepared string.
If no `prepfunc` is given, this type is the identity operation.
"""
def __init__(self, prepfunc=None):
super().__init__()
self.prepfunc = prepfunc
def coerce(self, v):
if not isinstance(v, str):
raise TypeError("must be a str object")
if self.prepfunc is not None:
return self.prepfunc(v)
return v
def parse(self, v):
if self.prepfunc is not None:
return self.prepfunc(v)
return v
class Integer(AbstractCDataType):
"""
Integer :term:`Character Data Type`, to the base 10.
"""
def coerce(self, v):
if not isinstance(v, numbers.Integral):
raise TypeError("must be integral number")
return int(v)
def parse(self, v):
return int(v)
class Float(AbstractCDataType):
"""
Floating point or decimal :term:`Character Data Type`.
"""
def coerce(self, v):
if not isinstance(v, (numbers.Real, decimal.Decimal)):
raise TypeError("must be real number")
return float(v)
def parse(self, v):
return float(v)
class Bool(AbstractCDataType):
"""
XML boolean :term:`Character Data Type`.
Parse the value as boolean:
* ``"true"`` and ``"1"`` are taken as :data:`True`,
* ``"false"`` and ``"0"`` are taken as :data:`False`,
* everything else results in a :class:`ValueError` exception.
"""
def coerce(self, v):
return bool(v)
def parse(self, v):
v = v.strip()
if v in ["true", "1"]:
return True
elif v in ["false", "0"]:
return False
else:
raise ValueError("not a boolean value")
def format(self, v):
if v:
return "true"
else:
return "false"
class DateTime(AbstractCDataType):
"""
ISO datetime :term:`Character Data Type`.
Parse the value as ISO datetime, possibly including microseconds and
timezone information.
Timezones are handled as constant offsets from UTC, and are converted to
UTC before the :class:`~datetime.datetime` object is returned (which is
correctly tagged with UTC tzinfo). Values without timezone specification
are not tagged.
If `legacy` is true, the formatted dates use the legacy date/time format
(``CCYYMMDDThh:mm:ss``), as used for example in :xep:`0082` or :xep:`0009`
(whereas in the latter it is not legacy, but defined by XML RPC). In any
case, parsing of the legacy format is transparently supported. Timestamps
in the legacy format are assumed to be in UTC, and datetime objects are
converted to UTC before emitting the legacy format. The timezone designator
is never emitted with the legacy format, and ignored if given.
This class makes use of :mod:`pytz`.
.. versionadded:: 0.5
The `legacy` argument was added.
"""
tzextract = re.compile("((Z)|([+-][0-9]{2}):([0-9]{2}))$")
def __init__(self, *, legacy=False):
super().__init__()
self.legacy = legacy
def coerce(self, v):
if not isinstance(v, datetime):
raise TypeError("must be a datetime object")
return v
def parse(self, v):
v = v.strip()
m = self.tzextract.search(v)
if m:
_, utc, hour_offset, minute_offset = m.groups()
if utc:
hour_offset = 0
minute_offset = 0
else:
hour_offset = int(hour_offset)
minute_offset = int(minute_offset)
tzinfo = pytz.utc
offset = timedelta(minutes=minute_offset + 60 * hour_offset)
v = v[:m.start()]
else:
tzinfo = None
offset = timedelta(0)
try:
dt = datetime.strptime(v, "%Y-%m-%dT%H:%M:%S.%f")
except ValueError:
try:
dt = datetime.strptime(v, "%Y-%m-%dT%H:%M:%S")
except ValueError:
dt = datetime.strptime(v, "%Y%m%dT%H:%M:%S")
tzinfo = pytz.utc
offset = timedelta(0)
return dt.replace(tzinfo=tzinfo) - offset
def format(self, v):
if v.tzinfo:
v = pytz.utc.normalize(v)
if self.legacy:
return v.strftime("%Y%m%dT%H:%M:%S")
result = v.strftime("%Y-%m-%dT%H:%M:%S")
if v.microsecond:
result += ".{:06d}".format(v.microsecond).rstrip("0")
if v.tzinfo:
result += "Z"
return result
class Date(AbstractCDataType):
"""
ISO date :term:`Character Data Type`.
Implement the Date type from :xep:`0082`.
Values must have the :class:`date` type, :class:`datetime` is forbidden to
avoid silent loss of information.
.. versionadded:: 0.5
"""
def parse(self, s):
return datetime.strptime(s, "%Y-%m-%d").date()
def coerce(self, v):
if not isinstance(v, date) or isinstance(v, datetime):
raise TypeError("must be a date object")
return v
class Time(AbstractCDataType):
"""
ISO time :term:`Character Data Type`.
Implement the Time type from :xep:`0082`.
Values must have the :class:`time` type, :class:`datetime` is forbidden to
avoid silent loss of information. Assignment of :class:`time` values in
time zones which are not UTC is not allowed either. The reason is that the
translation to UTC on formatting is not properly defined without an
accompanying date (think daylight saving time transitions, redefinitions of
time zones, …).
.. versionadded:: 0.5
"""
def parse(self, v):
v = v.strip()
m = DateTime.tzextract.search(v)
if m:
_, utc, hour_offset, minute_offset = m.groups()
if utc:
hour_offset = 0
minute_offset = 0
else:
hour_offset = int(hour_offset)
minute_offset = int(minute_offset)
tzinfo = pytz.utc
offset = timedelta(minutes=minute_offset + 60 * hour_offset)
v = v[:m.start()]
else:
tzinfo = None
offset = timedelta(0)
try:
dt = datetime.strptime(v, "%H:%M:%S.%f")
except ValueError:
dt = datetime.strptime(v, "%H:%M:%S")
return (dt.replace(tzinfo=tzinfo) - offset).timetz()
def format(self, v):
if v.tzinfo:
v = pytz.utc.normalize(v)
result = v.strftime("%H:%M:%S")
if v.microsecond:
result += ".{:06d}".format(v.microsecond).rstrip("0")
if v.tzinfo:
result += "Z"
return result
def coerce(self, t):
if not isinstance(t, time):
raise TypeError("must be a time object")
if t.tzinfo is None:
return t
if t.tzinfo == pytz.utc:
return t
raise ValueError("time must have UTC timezone or none at all")
class _BinaryType(AbstractCDataType):
"""
Implements pointful coercion for binary types.
"""
def coerce(self, v):
if isinstance(v, bytes):
return v
elif isinstance(v, (bytearray, array.array)):
return bytes(v)
raise TypeError("must be convertible to bytes")
class Base64Binary(_BinaryType):
"""
:term:`Character Data Type` for :class:`bytes` encoded as base64.
Parse the value as base64 and return the :class:`bytes` object obtained
from decoding.
If `empty_as_equal` is :data:`True`, an empty value is represented using a
single equal sign. This is used in the SASL protocol.
"""
def __init__(self, *, empty_as_equal=False):
super().__init__()
self._empty_as_equal = empty_as_equal
def parse(self, v):
return base64.b64decode(v)
def format(self, v):
if self._empty_as_equal and not v:
return "="
return base64.b64encode(v).decode("ascii")
class HexBinary(_BinaryType):
"""
:term:`Character Data Type` for :class:`bytes` encoded as hexadecimal.
Parse the value as hexadecimal blob and return the :class:`bytes` object
obtained from decoding.
"""
def parse(self, v):
return binascii.a2b_hex(v)
def format(self, v):
return binascii.b2a_hex(v).decode("ascii")
class JID(AbstractCDataType):
"""
:term:`Character Data Type` for :class:`aioxmpp.JID` objects.
Parse the value as Jabber ID using :meth:`~aioxmpp.JID.fromstr` and
return the :class:`aioxmpp.JID` object.
`strict` is passed to :meth:`~aioxmpp.JID.fromstr` and defaults to
false. See the :meth:`~aioxmpp.JID.fromstr` method for a rationale
and consider that :meth:`parse` is only called for input coming from the
outside.
"""
def __init__(self, *, strict=False):
super().__init__()
self.strict = strict
def coerce(self, v):
if not isinstance(v, structs.JID):
raise TypeError("{} object {!r} is not a JID".format(
type(v), v))
return v
def parse(self, v):
return structs.JID.fromstr(v, strict=self.strict)
class ConnectionLocation(AbstractCDataType):
"""
:term:`Character Data Type` for a hostname-port pair.
Parse the value as a host-port pair, as for example used for Stream
Management reconnection location advisories.
"""
def coerce(self, v):
if not isinstance(v, tuple):
raise TypeError("2-tuple required for ConnectionLocation")
if len(v) != 2:
raise TypeError("2-tuple required for ConnectionLocation")
addr, port = v
if not isinstance(port, numbers.Integral):
raise TypeError("port number must be integral number")
port = int(port)
if not (0 <= port <= 65535):
raise ValueError("port number {} out of range".format(port))
try:
addr = ipaddress.IPv4Address(addr)
except ValueError:
try:
addr = ipaddress.IPv6Address(addr)
except ValueError:
pass
return (addr, port)
def parse(self, v):
v = v.strip()
if v.endswith("]"):
# IPv6 address without port number
if not v.startswith("["):
raise ValueError(
"IPv6 address must be encapsulated in square brackets"
)
return self.coerce((
ipaddress.IPv6Address(v[1:-1]),
5222
))
addr, sep, port = v.rpartition(":")
if sep:
port = int(port)
else:
# with rpartition, the stuff is on the RHS when the separator was
# not found
addr = port
port = 5222
if addr.startswith("[") and addr.endswith("]"):
addr = ipaddress.IPv6Address(addr[1:-1])
elif ":" in addr:
raise ValueError(
"IPv6 address must be encapsulated in square brackets"
)
try:
addr = ipaddress.IPv4Address(addr)
except ValueError:
pass
return self.coerce((addr, port))
def format(self, v):
if isinstance(v[0], ipaddress.IPv6Address):
return "[{}]:{}".format(*v)
return ":".join(map(str, v))
class LanguageTag(AbstractCDataType):
"""
:term:`Character Data Type` for language tags.
Parses the value as Language Tag using
:meth:`~.structs.LanguageTag.fromstr`.
Type coercion requires that any value assigned to a descriptor using this
type is an instance of :class:`~.structs.LanguageTag`.
"""
def parse(self, v):
return structs.LanguageTag.fromstr(v)
def coerce(self, v):
if not isinstance(v, structs.LanguageTag):
raise TypeError("{!r} is not a LanguageTag", v)
return v
class JSON(AbstractCDataType):
"""
:term:`Character Data Type` for JSON formatted data.
.. versionadded:: 0.11
Upon deserialisation, character data is parsed as JSON using :mod:`json`.
On serialisation, the value is serialised as JSON. This implies that the
data must be JSON serialisable, but there is no check for that in
:meth:`coerce`, as this check would be (a) expensive to do for nested data
structures and (b) impossible to do for mutable data structures.
Example:
.. code-block:: python
class JSONContainer(aioxmpp.xso.XSO):
TAG = ("urn:xmpp:json:0", "json")
data = aioxmpp.xso.Text(
type_=aioxmpp.xso.JSON()
)
"""
def parse(self, v):
return json.loads(v)
def format(self, v):
return json.dumps(v)
def coerce(self, v):
return v
class TextChildMap(AbstractElementType):
"""
A type for use with :class:`.xso.ChildValueMap` and descendants of
:class:`.xso.AbstractTextChild`.
This type performs the packing and unpacking of language-text-pairs to and
from the `xso_type`. `xso_type` must have an interface compatible with
:class:`.xso.AbstractTextChild`, which means that it must have the language
and text at :attr:`~.xso.AbstractTextChild.lang` and
:attr:`~.xso.AbstractTextChild.text`, respectively and support the
same-named keyword arguments for those attributes at the constructor.
For an example see the source of :class:`aioxmpp.Message`.
.. versionadded:: 0.5
"""
def __init__(self, xso_type):
super().__init__()
self.xso_type = xso_type
def get_xso_types(self):
return [self.xso_type]
def unpack(self, obj):
return obj.lang, obj.text
def pack(self, item):
lang, text = item
xso = self.xso_type(text=text, lang=lang)
return xso
class EnumCDataType(AbstractCDataType):
"""
Use an :class:`enum.Enum` as type for an XSO descriptor.
:param enum_class: The :class:`~enum.Enum` to use as type.
:param nested_type: A type which can handle the values of the enumeration
members.
:type nested_type: :class:`AbstractCDataType`
:param allow_coerce: Allow coercion of different types to enumeration
values.
:type allow_coerce: :class:`bool`
:param deprecate_coerce: Emit :class:`DeprecationWarning` when coercion
occurs. Requires (but does not imply)
`allow_coerce`.
:type deprecate_coerce: :class:`int` or :class:`bool`
:param allow_unknown: If true, unknown values are converted to
:class:`Unknown` instances when parsing values from
the XML stream.
:type allow_unknown: :class:`bool`
:param accept_unknown: If true, :class:`Unknown` instances are passed
through :meth:`coerce` and can thus be assigned to
descriptors using this type.
:type accept_unknown: :class:`bool`
:param pass_unknown: If true, unknown values are accepted unmodified (both
on the receiving and on the sending side). It is useful for some
:class:`enum.IntEnum` use cases.
:type pass_unknown: :class:`bool`
A descriptor using this type will accept elements from the given
`enum_class` as values. Upon serialisiation, the :attr:`value` of the
enumeration element is taken and formatted through the given `nested_type`.
Normally, :meth:`coerce` will raise :class:`TypeError` for any value which
is not an instance of `enum_class`. However, if `allow_coerce` is true, the
value is passed to the `enum_class` constructor and the result is returned;
the :class:`ValueError` raised from the `enum_class` constructor if an
invalid value is passed propagates unmodified.
.. note::
When using `allow_coerce`, keep in mind that this may have surprising
effects for users. Coercion means that the value assigned to an
attribute and the value subsequently read from that attribute may not
be the same; this may be very surprising to users::
class E(enum.Enum):
X = "foo"
class SomeXSO(xso.XSO):
attr = xso.Attr("foo", xso.EnumCDataType(E, allow_coerce=True))
x = SomeXSO()
x.attr = "foo"
assert x.attr == "foo" # assertion fails!
To allow coercion transitionally while moving from e.g. string-based values
to a proper enum, `deprecate_coerce` can be used. In that case, a
:class:`DeprecationWarning` (see :mod:`warnings`) is emitted when coercion
takes place, to warn users about future removal of the coercion capability.
If `deprecate_coerce` is an integer, it is used as the stacklevel argument
for the :func:`warnings.warn` call. If it is :data:`True`, the stacklevel
is 4, which leads to the warning pointing to a descriptor assignment when
used with XSO descriptors.
Handling of :class:`Unknown` values: Using `allow_unknown` and
`accept_unknown` is advisable to stay compatible with future protocols,
which is why both are enabled by default. Considering that constructing an
:class:`Unknown` value needs to be done explicitly in code, it is unlikely
that a user will *accidentally* assign an unspecified value to a descriptor
using this type with `accept_unknown`.
`pass_unknown` requires `allow_unknown` and `accept_unknown`. When set to
true, values which are not a member of `enum_class` are used without
modification (but they are validated against the `nested_type`). This
applies to both the sending and the receiving side. The intended use case
is with :class:`enum.IntEnum` classes. If a :class:`Unknown` value is
passed, it is unwrapped and treated as if the original value had been
passed.
Example::
class SomeEnum(enum.Enum):
X = 1
Y = 2
Z = 3
class SomeXSO(xso.XSO):
attr = xso.Attr(
"foo",
type_=xso.EnumCDataType(
SomeEnum,
# have to use integer, because the value of e.g. SomeEnum.X
# is integer!
xso.Integer()
),
)
.. versionchanged:: 0.10
Support for `pass_unknown` was added.
"""
def __init__(self, enum_class, nested_type=String(), *,
allow_coerce=False,
deprecate_coerce=False,
allow_unknown=True,
accept_unknown=True,
pass_unknown=False):
if pass_unknown and (not allow_unknown or not accept_unknown):
raise ValueError(
"pass_unknown requires allow_unknown and accept_unknown"
)
super().__init__()
self.nested_type = nested_type
self.enum_class = enum_class
self.allow_coerce = allow_coerce
self.deprecate_coerce = deprecate_coerce
self.accept_unknown = accept_unknown
self.allow_unknown = allow_unknown
self.pass_unknown = pass_unknown
def coerce(self, value):
if (not self.pass_unknown and self.accept_unknown and
isinstance(value, Unknown)):
return value
if isinstance(value, self.enum_class):
return value
if self.allow_coerce:
if self.deprecate_coerce:
stacklevel = (4 if self.deprecate_coerce is True
else self.deprecate_coerce)
warnings.warn(
"assignment of non-enum values to this descriptor is"
" deprecated",
DeprecationWarning,
stacklevel=stacklevel,
)
value = self.nested_type.coerce(value)
try:
return self.enum_class(value)
except ValueError:
if self.pass_unknown:
return value
raise
if self.pass_unknown:
value = self.nested_type.coerce(value)
return value
raise TypeError("not a valid {} value: {!r}".format(
self.enum_class,
value,
))
def parse(self, s):
parsed = self.nested_type.parse(s)
try:
return self.enum_class(parsed)
except ValueError:
if self.pass_unknown:
return parsed
if self.allow_unknown:
return Unknown(parsed)
raise
def format(self, v):
if self.pass_unknown and not isinstance(v, self.enum_class):
return self.nested_type.format(v)
return self.nested_type.format(v.value)
class EnumElementType(AbstractElementType):
"""
Use an :class:`enum.Enum` as type for an XSO descriptor.
:param enum_class: The :class:`~enum.Enum` to use as type.
:param nested_type: Type which describes the value type of the
`enum_class`.
:type nested_type: :class:`AbstractElementType`
:param allow_coerce: Allow coercion of different types to enumeration
values.
:type allow_coerce: :class:`bool`
:param deprecate_coerce: Emit :class:`DeprecationWarning` when coercion
occurs. Requires (but does not imply)
`allow_coerce`.
:type deprecate_coerce: :class:`int` or :class:`bool`
:param allow_unknown: If true, unknown values are converted to
:class:`Unknown` instances when parsing values from
the XML stream.
:type allow_unknown: :class:`bool`
:param accept_unknown: If true, :class:`Unknown` instances are passed
through :meth:`coerce` and can thus be assigned to
descriptors using this type.
:type allow_unknown: :class:`bool`
A descriptor using this type will accept elements from the given
`enum_class` as values. Upon serialisiation, the :attr:`value` of the
enumeration element is taken and packed through the given `nested_type`.
Normally, :meth:`coerce` will raise :class:`TypeError` for any value which
is not an instance of `enum_class`. However, if `allow_coerce` is true, the
value is passed to the `enum_class` constructor and the result is returned;
the :class:`ValueError` raised from the `enum_class` constructor if an
invalid value is passed propagates unmodified.
.. seealso::
:class:`EnumCDataType`
for a detailed discussion on the implications of coercion.
Handling of :class:`Unknown` values: Using `allow_unknown` and
`accept_unknown` is advisable to stay compatible with future protocols,
which is why both are enabled by default. Considering that constructing an
:class:`Unknown` value needs to be done explicitly in code, it is unlikely
that a user will *accidentally* assign an unspecified value to a descriptor
using this type with `accept_unknown`.
"""
def __init__(self, enum_class, nested_type, *,
allow_coerce=False,
deprecate_coerce=False,
allow_unknown=True,
accept_unknown=True):
super().__init__()
self.nested_type = nested_type
self.enum_class = enum_class
self.allow_coerce = allow_coerce
self.deprecate_coerce = deprecate_coerce
self.accept_unknown = accept_unknown
self.allow_unknown = allow_unknown
def get_xso_types(self):
return self.nested_type.get_xso_types()
def coerce(self, value):
if self.accept_unknown and isinstance(value, Unknown):
return value
if self.allow_coerce:
if self.deprecate_coerce:
if isinstance(value, self.enum_class):
return value
stacklevel = (4 if self.deprecate_coerce is True
else self.deprecate_coerce)
warnings.warn(
"assignment of non-enum values to this descriptor is"
" deprecated",
DeprecationWarning,
stacklevel=stacklevel,
)
return self.enum_class(value)
if isinstance(value, self.enum_class):
return value
raise TypeError("not a valid {} value: {!r}".format(
self.enum_class,
value,
))
def unpack(self, s):
parsed = self.nested_type.unpack(s)
try:
return self.enum_class(parsed)
except ValueError:
if self.allow_unknown:
return Unknown(parsed)
raise
def pack(self, v):
return self.nested_type.pack(v.value)
class AbstractValidator(metaclass=abc.ABCMeta):
"""
This is the interface all validators must implement. In addition, a
validators documentation should clearly state on which types it operates.
.. automethod:: validate
.. automethod:: validate_detailed
"""
def validate(self, value):
"""
Return :data:`True` if the `value` adheres to the restrictions imposed
by this validator and :data:`False` otherwise.
By default, this method calls :meth:`validate_detailed` and returns
:data:`True` if :meth:`validate_detailed` returned an empty result.
"""
return not self.validate_detailed(value)
@abc.abstractmethod
def validate_detailed(self, value):
"""
Return an empty list if the `value` adheres to the restrictions imposed
by this validator.
If the value does not comply, return a list of
:class:`~aioxmpp.errors.UserValueError` instances which each represent
a condition which was violated in a human-readable way.
"""
class RestrictToSet(AbstractValidator):
"""
Restrict the possible values to the values from `values`. Operates on any
types.
"""
def __init__(self, values):
self.values = frozenset(values)
def validate_detailed(self, value):
from ..errors import UserValueError
if value not in self.values:
return [
UserValueError(i18n._("{} is not an allowed value"),
value)
]
return []
class Nmtoken(AbstractValidator):
"""
Restrict the possible strings to the NMTOKEN specification of XML Schema
Definitions. The validator only works with strings.
.. warning::
This validator is probably incorrect. It is a good first line of defense
to avoid creating obvious incorrect output and should not be used as
input validator.
It most likely falsely rejects valid values and may let through invalid
values.
"""
VALID_CATS = {
"Ll", "Lu", "Lo", "Lt", "Nl", # Name start
"Mc", "Me", "Mn", "Lm", "Nd", # Name without name start
}
ADDITIONAL = frozenset(":_.-\u06dd\u06de\u06df\u00b7\u0387\u212e")
UCD = unicodedata.ucd_3_2_0
@classmethod
def _validate_chr(cls, c):
if c in cls.ADDITIONAL:
return True
if 0xf900 < ord(c) < 0xfffe:
return False
if 0x20dd <= ord(c) <= 0x20e0:
return False
if cls.UCD.category(c) not in cls.VALID_CATS:
return False
return True
def validate_detailed(self, value):
from ..errors import UserValueError
if not all(map(self._validate_chr, value)):
return [
UserValueError(i18n._("{} is not a valid NMTOKEN"),
value)
]
return []
class IsInstance(AbstractValidator):
"""
This validator checks that the value is an instance of any of the classes
given in `valid_classes`.
`valid_classes` is *not* copied into the :class:`IsInstance` instance, but
instead shared; it can be mutated after the construction of
:class:`IsInstance` to allow addition and removal of classes.
"""
def __init__(self, valid_classes):
self.classes = valid_classes
def validate_detailed(self, v):
from ..errors import UserValueError
if not isinstance(v, tuple(self.classes)):
return [
UserValueError(
i18n._("{} is of incorrect type (must be one of {})"),
v,
", ".join(type_.__name__
for type_ in self.classes)
)
]
return []
class NumericRange(AbstractValidator):
"""
To be used with orderable types, such as :class:`.DateTime` or
:class:`.Integer`.
The value is enforced to be within *[min, max]* (this is the interval from
`min_` to `max_`, including both ends).
Setting `min_` or `max_` to :data:`None` disables enforcement of that end
of the interval. A common use is ``NumericRange(min_=1)`` in conjunction
with :class:`.Integer` to enforce the use of positive integers.
.. versionadded:: 0.6
"""
def __init__(self, min_=None, max_=None):
super().__init__()
self.min_ = min_
self.max_ = max_
def validate_detailed(self, v):
from ..errors import UserValueError
if self.min_ is None:
if self.max_ is None:
return []
if not v <= self.max_:
return [
UserValueError(
i18n._("{} is too large (max is {})"),
v,
self.max_
)
]
elif self.max_ is None:
if not self.min_ <= v:
return [
UserValueError(
i18n._("{} is too small (min is {})"),
v,
self.max_
)
]
elif not self.min_ <= v <= self.max_:
return [
UserValueError(
i18n._("{} is out of bounds ({}..{})"),
v,
self.min_,
self.max_
)
]
return []
_Undefined = object()
def EnumType(enum_class, nested_type=_Undefined, **kwargs):
"""
Create and return a :class:`EnumCDataType` or :class:`EnumElementType`,
depending on the type of `nested_type`.
If `nested_type` is a :class:`AbstractCDataType` or omitted, a
:class:`EnumCDataType` is constructed. Otherwise, :class:`EnumElementType`
is used.
The arguments are forwarded to the respective class’ constructor.
.. versionadded:: 0.10
.. deprecated:: 0.10
This function was introduced to ease the transition in 0.10 from
a unified :class:`EnumType` to split :class:`EnumCDataType` and
:class:`EnumElementType`.
It will be removed in 1.0.
"""
if nested_type is _Undefined:
return EnumCDataType(enum_class, **kwargs)
if isinstance(nested_type, AbstractCDataType):
return EnumCDataType(enum_class, nested_type, **kwargs)
else:
return EnumElementType(enum_class, nested_type, **kwargs)
benchmarks/ 0000775 0000000 0000000 00000000000 14160146213 0013171 5 ustar 00root root 0000000 0000000 benchmarks/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0015307 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
benchmarks/test_cache.py 0000664 0000000 0000000 00000003633 14160146213 0015652 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import io
import unittest
import random
import aioxmpp.cache
from aioxmpp.benchtest import times, timed, record
class TestLRUDict(unittest.TestCase):
KEY = "aioxmpp.cache", "LRUDict"
@times(1000)
def test_random_access(self):
key = self.KEY + ("random_access",)
N = 1000
lru_dict = aioxmpp.cache.LRUDict()
lru_dict.maxsize = N
keys = [object() for i in range(N)]
for i in range(N):
lru_dict[keys[i]] = object()
with timed() as t:
for i in range(N):
lru_dict[keys[random.randrange(0, N)]]
record(key, t.elapsed, "s")
@times(1000)
def test_inserts(self):
key = self.KEY + ("inserts",)
N = 1000
lru_dict = aioxmpp.cache.LRUDict()
lru_dict.maxsize = N
keys = [object() for i in range(N)]
for i in range(N):
lru_dict[keys[i]] = object()
with timed() as t:
for i in range(N):
lru_dict[object()] = object()
record(key, t.elapsed, "s")
benchmarks/test_xml.py 0000664 0000000 0000000 00000012626 14160146213 0015411 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_xml.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
# .
#
########################################################################
import base64
import io
import itertools
import unittest
import random
import aioxmpp.xso as xso
import aioxmpp.xml
from aioxmpp.benchtest import times, timed, record
class ShallowRoot(xso.XSO):
TAG = ("uri:test", "shallow")
attr = xso.Attr("a")
data = xso.Text()
def __init__(self, scale=1):
super().__init__()
self.attr = "foobar"*(2*scale)
self.data = "fnord"*(10*scale)
class DeepLeaf(xso.XSO):
TAG = ("uri:test", "leaf")
data = xso.Text()
def generate(self, rng, depth):
self.data = "foo" * (2*rng.randint(1, 10))
class DeepNode(xso.XSO):
TAG = ("uri:test", "node")
data = xso.Attr("attr")
children = xso.ChildList([DeepLeaf])
def generate(self, rng, depth):
self.data = "foo" * (2*rng.randint(1, 10))
if depth >= 5:
cls = DeepLeaf
else:
cls = DeepNode
self.children.append(cls())
for i in range(rng.randint(2, 10)):
if rng.randint(1, 10) == 1:
item = DeepNode()
else:
item = DeepLeaf()
self.children.append(item)
for item in self.children:
item.generate(rng, depth+1)
DeepNode.register_child(DeepNode.children, DeepNode)
class DeepRoot(xso.XSO):
TAG = ("uri:test", "root")
children = xso.ChildList([DeepLeaf, DeepNode])
def generate(self, rng):
self.children[:] = [DeepNode() for i in range(3)]
for child in self.children:
child.generate(rng, 1)
class TestxmlValidateNameValue_str(unittest.TestCase):
KEY = "aioxmpp.xml", "xmlValidateNameValue"
def test_exhaustive(self):
validate = aioxmpp.xml.xmlValidateNameValue_str
r1 = range(0, 0xd800)
r2 = range(0xe000, 0xf0000)
range_iter = itertools.chain(
# exclude surrogates
r1, r2,
)
with timed() as timer:
for cp in range_iter:
validate(chr(cp))
record(self.KEY + ("exhaustive",),
timer.elapsed / (len(r1) + len(r2)),
"s")
def test_exhaustive_dualchar(self):
validate = aioxmpp.xml.xmlValidateNameValue_str
strs = ["x" + chr(cp) for cp in range(0, 0xd800)]
with timed() as timer:
for s in strs:
validate(s)
record(self.KEY + ("exhaustive_dualchar",),
timer.elapsed / (len(strs)),
"s")
def test_random_strings(self):
key = self.KEY + ("random",)
validate = aioxmpp.xml.xmlValidateNameValue_str
rng = random.Random(1)
samples = []
for i in range(1000):
samples.append(base64.b64encode(
random.getrandbits(120).to_bytes(120//8, 'little')
).decode("ascii").rstrip("="))
for sample in samples:
with timed() as timer:
validate(sample)
record(key, timer.elapsed, "s")
class Testwrite_single_xso(unittest.TestCase):
KEY = "aioxmpp.xml", "write_single_xso"
@classmethod
def setUpClass(cls):
rng = random.Random(1)
cls.deep_samples = [
DeepRoot()
for i in range(10)
]
for sample in cls.deep_samples:
with timed(cls.KEY+("deep", "generate")):
sample.generate(rng)
def setUp(self):
self.buf = io.BytesIO(bytearray(1024*1024))
def _reset_buffer(self):
self.buf.seek(0)
@times(1000)
def test_shallow_and_small(self):
key = self.KEY + ("shallow+small",)
item = ShallowRoot()
self._reset_buffer()
with timed() as t:
aioxmpp.xml.write_single_xso(item, self.buf)
record(key+("sz",), self.buf.tell(), "B")
record(key+("rate",), self.buf.tell() / t.elapsed, "B/s")
@times(1000)
def test_shallow_and_large(self):
key = self.KEY + ("shallow+large",)
item = ShallowRoot(scale=100)
self._reset_buffer()
with timed() as t:
aioxmpp.xml.write_single_xso(item, self.buf)
record(key+("sz",), self.buf.tell(), "B")
record(key+("rate",), self.buf.tell() / t.elapsed, "B/s")
@times(1000, pass_iteration=True)
def test_deep(self, iteration=None):
key = self.KEY + ("deep",)
item = self.deep_samples[iteration % len(self.deep_samples)]
self._reset_buffer()
with timed() as t:
aioxmpp.xml.write_single_xso(item, self.buf)
record(key+("sz",), self.buf.tell(), "B")
record(key+("rate",), self.buf.tell() / t.elapsed, "B/s")
data/ 0000775 0000000 0000000 00000000000 14160146213 0011765 5 ustar 00root root 0000000 0000000 data/gen-features-enum.xsl 0000664 0000000 0000000 00000002121 14160146213 0016040 0 ustar 00root root 0000000 0000000
class Features(Enum):
"""
.. attribute::
:annotation: = ""
"""
= \
""
data/xep0060-features.xml 0000664 0000000 0000000 00000015277 14160146213 0015441 0 ustar 00root root 0000000 0000000 http://jabber.org/protocol/pubsub#access-authorizeThe default node access model is authorize.XEP-0060http://jabber.org/protocol/pubsub#access-openThe default node access model is open.XEP-0060http://jabber.org/protocol/pubsub#access-presenceThe default node access model is presence.XEP-0060http://jabber.org/protocol/pubsub#access-rosterThe default node access model is roster.XEP-0060http://jabber.org/protocol/pubsub#access-whitelistThe default node access model is whitelist.XEP-0060http://jabber.org/protocol/pubsub#auto-createThe service supports automatic creation of nodes on first publish.XEP-0060http://jabber.org/protocol/pubsub#auto-subscribeThe service supports automatic subscription to a nodes based on presence subscription.XEP-0060http://jabber.org/protocol/pubsub#collectionsCollection nodes are supported.XEP-0248http://jabber.org/protocol/pubsub#config-nodeConfiguration of node options is supported.XEP-0060http://jabber.org/protocol/pubsub#create-and-configureSimultaneous creation and configuration of nodes is supported.XEP-0060http://jabber.org/protocol/pubsub#create-nodesCreation of nodes is supported.XEP-0060http://jabber.org/protocol/pubsub#delete-itemsDeletion of items is supported.XEP-0060http://jabber.org/protocol/pubsub#delete-nodesDeletion of nodes is supported.XEP-0060http://jabber.org/protocol/pubsub#filtered-notificationsThe service supports filtering of notifications based on Entity Capabilities.XEP-0060http://jabber.org/protocol/pubsub#get-pendingRetrieval of pending subscription approvals is supported.XEP-0060http://jabber.org/protocol/pubsub#instant-nodesCreation of instant nodes is supported.XEP-0060http://jabber.org/protocol/pubsub#item-idsPublishers may specify item identifiers.XEP-0060http://jabber.org/protocol/pubsub#last-published
The service supports sending of the last published item to new
subscribers and to newly available resources.
XEP-0060http://jabber.org/protocol/pubsub#leased-subscriptionTime-based subscriptions are supported.XEP-0060http://jabber.org/protocol/pubsub#manage-subscriptionsNode owners may manage subscriptions.XEP-0060http://jabber.org/protocol/pubsub#member-affiliationThe member affiliation is supported.XEP-0060http://jabber.org/protocol/pubsub#meta-dataNode meta-data is supported.XEP-0060http://jabber.org/protocol/pubsub#modify-affiliationsNode owners may modify affiliations.XEP-0060http://jabber.org/protocol/pubsub#multi-collectionA single leaf node can be associated with multiple collections.XEP-0060http://jabber.org/protocol/pubsub#multi-subscribeA single entity may subscribe to a node multiple times.XEP-0060http://jabber.org/protocol/pubsub#outcast-affiliationThe outcast affiliation is supported.XEP-0060http://jabber.org/protocol/pubsub#persistent-itemsPersistent items are supported.XEP-0060http://jabber.org/protocol/pubsub#presence-notificationsPresence-based delivery of event notifications is supported.XEP-0060http://jabber.org/protocol/pubsub#presence-subscribeImplicit presence-based subscriptions are supported.XEP-0060http://jabber.org/protocol/pubsub#publishPublishing items is supported.XEP-0060http://jabber.org/protocol/pubsub#publish-optionsPublication with publish options is supported.XEP-0060http://jabber.org/protocol/pubsub#publish-only-affiliationThe publish-only affiliation is supported.XEP-0060http://jabber.org/protocol/pubsub#publisher-affiliationThe publisher affiliation is supported.XEP-0060http://jabber.org/protocol/pubsub#purge-nodesPurging of nodes is supported.XEP-0060http://jabber.org/protocol/pubsub#retract-itemsItem retraction is supported.XEP-0060http://jabber.org/protocol/pubsub#retrieve-affiliationsRetrieval of current affiliations is supported.XEP-0060http://jabber.org/protocol/pubsub#retrieve-defaultRetrieval of default node configuration is supported.XEP-0060http://jabber.org/protocol/pubsub#retrieve-itemsItem retrieval is supported.XEP-0060http://jabber.org/protocol/pubsub#retrieve-subscriptionsRetrieval of current subscriptions is supported.XEP-0060http://jabber.org/protocol/pubsub#subscribeSubscribing and unsubscribing are supported.XEP-0060http://jabber.org/protocol/pubsub#subscription-optionsConfiguration of subscription options is supported.XEP-0060http://jabber.org/protocol/pubsub#subscription-notificationsNotification of subscription state changes is supported.XEP-0060
data/xhtml-im-sanitise.xsl 0000664 0000000 0000000 00000006505 14160146213 0016077 0 ustar 00root root 0000000 0000000
ERROR: The top-level element must be either {http://jabber.org/protocol/xhtml-im}html or {http://www.w3.org/1999/xhtml}body: Found {} instead.
docs/ 0000775 0000000 0000000 00000000000 14160146213 0012004 5 ustar 00root root 0000000 0000000 docs/CONTRIBUTING.rst 0000664 0000000 0000000 00000002744 14160146213 0014454 0 ustar 00root root 0000000 0000000 Contribution guidelines
#######################
* Adhere to `PEP 8 `_ wherever
possible and deviate from it where it makes sense. Follow the style of the
code around you.
* Make sure the test suite passes with and without your change. If the test
suite does not pass without your change, check if an issue exists for that
failure, and if not, `open an issue on GitHub
`_ or post to the mailing list
(see the ``README.rst`` for details) if you do not have a GitHub account.
* Write tests for your code, preferably in test-driven development style.
Patches without good test coverage are less likely to be accepted, because
someone will have to write tests for them.
* If possible, get in touch with the developers via the `mailing list
`_ or the
XMPP Multi-User Chat before and while you are working on your patch. See the
``README.rst`` for contact opportunities.
* If your code implements a XEP, double-check that the schema in the XEP matches
the examples and the text. It is tempting to simply convert the schema to XSO
classes, but often the schema is slightly wrong. `The schema is only
informative!
`_.
* By contributing, you agree that your code is going to be licensed under the
LGPLv3+ (see ``COPYING.LESSER``), like the rest of aioxmpp.
docs/Makefile 0000664 0000000 0000000 00000012737 14160146213 0013456 0 ustar 00root root 0000000 0000000 # Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = sphinx-data/build
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
help:
@echo "Please use \`make ' where is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
clean:
-rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/asyncio_xmpp.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/asyncio_xmpp.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/asyncio_xmpp"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/asyncio_xmpp"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
docs/README.rst 0000664 0000000 0000000 00000002731 14160146213 0013476 0 ustar 00root root 0000000 0000000 Documentation
#############
Online documentation
--------------------
If you want to read documentation without building it yourself, please check the
online documentation available on `our website
`_. It is automatically updated when
a commit is pushed to devel. Documentation for specific aioxmpp versions is
available at ``_.
Building the documentation
--------------------------
To build the documentation, ``aioxmpp`` and all of its components need to be
importable. This means that you need to have all ``aioxmpp`` dependencies
installed. In addition, `sphinx `_ as well
as the alabaster theme for sphinx are required. Make sure to install sphinx for
python3!
If the executable of sphinx for python3 is not called ``sphinx-build-3`` on your
system, export the ``SPHINXBUILD`` environment variable with the name of the
executable for the makefile to use. For example, if the executable is called
``sphinx-build`` on your system, either add ``SPHINXBUILD=sphinx-build`` to the
make commandline or export it using::
export SPHINXBUILD=sphinx-build
Once that is done, you can navigate **to the root of the repository** and build
the documentation using::
make docs-html
The resulting documentation is available in
``docs/sphinx-data/build/html/index.html``. To build the documentation and view
it in your favourite browser immediately, use::
make docs-view-html
docs/TODO.rst 0000664 0000000 0000000 00000001104 14160146213 0013277 0 ustar 00root root 0000000 0000000 TODO
####
XEPs to directly implement support for
======================================
* XEP-0050: Ad-Hoc Commands (serving and querying)
* XEP-0085: Chat State Notifications (with handling for legacy XEP-0022)
* XEP-0184: Message Delivery Reciepts (with handling for legacy XEP-0022)
* XEP-0280: Message Carbons (with support for special handling w.r.t. XEP-0085
and XEP-0184)
Other stuff
===========
* polish pubsub and muc implementation
@stanza_model
=============
* Handle multiple children in Child / ChildText
@stanza_types
=============
* Validator arithmetic?
docs/api/ 0000775 0000000 0000000 00000000000 14160146213 0012555 5 ustar 00root root 0000000 0000000 docs/api/aioxmpp.rst 0000664 0000000 0000000 00000000030 14160146213 0014755 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp
docs/api/changelog.rst 0000664 0000000 0000000 00000250262 14160146213 0015245 0 ustar 00root root 0000000 0000000 .. _changelog:
Changelog
#########
.. _api-changelog-0.13:
Version 0.13.1
==============
.. note::
0.13.0 existed briefly, but a fatal bug in the :xep:`0359` support meant
that aioxmpp would drop *all* message stanzas with a XEP-0359 Stanza ID
tag in them, so that has been yanked.
New XEP implementations
-----------------------
* Support for the :xep:`0359` (Unique and Stable Stanza IDs) schema in
:mod:`aioxmpp.misc`.
New examples
------------
* `muc_moderate_message.py` to remove a message (using a makeshift
implementation of :xep:`0425`) from a MUC.
* `get_muc_affiliations.py` to query affiliated JIDs from a given MUC.
* `get_external_services.py` to query external services exposed via
:xep:`215`. This is useful in the context of testing STUN/TURN
configuration.
* `ping.py`, to send :xep:`199` to one or more remote JIDs.
* `roster_watch.py`: Stay connected and watch for roster changes.
* `set_muc_avatar.py`: Set the avatar of a MUC.
New major features
------------------
Breaking changes
----------------
* *Potentially breaking change*: :meth:`aioxmpp.muc.Room.on_presence_changed`
now emits for *all* normal (non-unavailable, non-roster-management) presence
stanzas received from an occupant.
Previously, the signal would only emit for cases where the presence show or
the status text had changed. This, however, made it impossible for user code
to stay up-to-date with the contents of custom extensions transmitted via
presence stanzas.
This was reported on
`GitHub as issue #341 `_ by
`@raj2569 `_.
* The dependencies and compatibility has been improved, potentially breaking
some setups. aioxmpp now supports Python 3.10 and is better prepared for
Python 3.11, has bumped some of its dependencies and requires a more modern
version of Sphinx to build the docs.
This is probably the last release to support Python 3.5.
Minor features and bug fixes
----------------------------
* :meth:`aioxmpp.muc.Service.get_affiliated` added to query affiliations of
a MUC.
* :meth:`aioxmpp.vcard.Service.set_vcard` now supports an optional `jid`
argument to set vCards of foreign entities (e.g. to set a MUC avatar).
.. _api-changelog-0.12:
Version 0.12
============
* Drop support for Python 3.4. This includes migrating to using ``async def``
instead of ``@asyncio.coroutine`` consistently. Future changes will include
using type annotations.
* Add ``--e2etest-only`` flag to the e2etest nose plugin. This flag will skip
any test case not derived from :class:`aioxmpp.e2etest.TestCase`. The use
case for this is to use the aioxmpp test suite to test other servers in their
CI.
* :class:`aioxmpp.e2etest.provision.StaticPasswordProvisioner`
* Fix :func:`aioxmpp.jid_escape` double escaping sequences in some
circumstances.
* Support for Python 3.9.
* Make the stream header customizable for users of
:class:`aioxmpp.protocol.XMLStream`.
Version 0.12.1
--------------
* Fix leaking :class:`asyncio.Future` instances from internal functions in
:mod:`aioxmpp.protocol`, causing annoying (but harmless) error log messages
from asyncio.
`Issue #358 `_ reported by
`@pszafer `_, thanks.
* Allow keyword arguments being passed to ``AUTO_FUTURE`` callback listeners.
This is to support use cases where callbacks are extended with keyword
arguments in subclasses, as extensively used in :mod:`aioxmpp.im`.
`Issue #360 `_ reported by
`@zak333 `_, thanks.
Version 0.12.2
--------------
* Fix incorrect use of :meth:`aioxmpp.muc.service.Room.on_exit` in
:meth:`aioxmpp.muc.service.Room.leave` which causes the future to not work
correctly.
Again reported by `@zak333 `_, thanks.
.. _api-changelog-0.11:
Version 0.11
============
New XEP implementations
-----------------------
* Support for the :xep:`27` (Current Jabber OpenPGP Usage) schema in
:mod:`aioxmpp.misc`.
* :xep:`47` (In-Band Bytestreams), see :mod:`aioxmpp.ibb`.
* The :xep:`106` (JID Escaping) encoding can now be used via
:func:`aioxmpp.jid_escape`, :func:`aioxmpp.jid_unescape`.
* `@LukeMarlin `_ contributed support for the
:xep:`308` schema in :mod:`aioxmpp.misc`.
* The :xep:`335` (JSON Containers) schema is available for use via
:class:`aioxmpp.misc.JSONContainer`.
* Implement support for :xep:`410` (MUC Self-Ping (Schrödinger’s Chat)).
This introduces two new signals to :class:`aioxmpp.muc.Room` objects:
- :meth:`~aioxmpp.muc.Room.on_muc_stale`: Emits when a possible connectivity
issue with the MUC is detected, but it is unclear whether the user is still
joined or not and/or whether messages are being lost.
- :meth:`~aioxmpp.muc.Room.on_muc_fresh`: Emits when a possible connectivity
issue with the MUC is detected as resolved and the user is still joined.
Presence may be out-of-sync and messages may have been lost, however.
If a connectivity issue which has caused the user to be removed from the MUC
is detected, the appropriate signals (with
:attr:`aioxmpp.muc.LeaveMode.DISCONNECTED`) are emitted, *or* the room is
automatically re-joined if it is set to
:attr:`~aioxmpp.muc.Room.muc_autorejoin` (no history is requested on this
rejoin).
In addition to that, the :meth:`aioxmpp.MUCClient.cycle` method has been
introduced. It allows an application to leave and join a MUC in quick
succession using without discarding the :class:`aioxmpp.muc.Room` object
(just like a stream disconnect would). This is useful to deal with stale
situations by forcing a resync.
Security Fixes
--------------
* CVE-2019-1000007: Fix incorrect error handling in :mod:`aioxmpp.xso` when a
suppressing :meth:`aioxmpp.xso.XSO.xso_error_handler` is in use.
Under certain circumstances, it is possible that the handling of suppressed
error causes another error later on because the parsing stack mis-counts the
depth in which it is inside the XML tree. This makes elements appear in the
wrong place, typically leading to further errors.
In the worst case, using a suppressing
:meth:`~aioxmpp.xso.XSO.xso_error_handler` in specific circumstances can be
vulnerable to denial of service and data injection into the XML stream.
(The fix was also backported to 0.10.3.)
New major features
------------------
* The :mod:`aioxmpp.pubsub` implementation gained support for node
configuration and the related publish-options. This is vital for proper
operation of private storage in PEP.
Relevant additions are:
* :meth:`aioxmpp.PubSubClient.get_node_config`
* :meth:`aioxmpp.PubSubClient.set_node_config`
* :class:`aioxmpp.pubsub.NodeConfigForm`
* The new ``publish_options`` argument to
:meth:`aioxmpp.PubSubClient.publish`
* The new ``access_model`` argument to :meth:`aioxmpp.PEPClient.publish`
* The new :meth:`aioxmpp.Client.on_stream_resumed` event allows services and
application code to learn when the stream was resumed after it suspended due
to loss of connectivity. This is the counterpart to
:meth:`aioxmpp.Client.on_stream_suspended`.
This allows services and application code to defer actions until the stream
is alive again. While this is generally not necessary, it can be good to
delay periodic tasks or bulk operations in order to not overload the newly
established stream with queued messages.
New examples
------------
Breaking changes
----------------
* The undocumented and unused descriptors :attr:`aioxmpp.Message.ext`
and :attr:`aioxmpp.Presence.ext` were removed. If your code relies on them
you can instead patch a descriptor to the class (with a prefix that uniquely
identifies your extension).
A good example is how aioxmpp itself makes use of that feature in
:mod:`aioxmpp.misc`.
* :mod:`aioxmpp.stringprep` now uses the Unicode database in version 3.2.0 as
specified in :rfc:`3454`.
* The way the topological sort of service dependencies is handled was
simplified: We no longer keep a toposort of all service classes.
*This implies that :class:`Service` subclasses are no longer ordered objects.*
However, we still guarantee a runtime error when a dependency loop is
declared—if a class uses only one of `ORDER_BEFORE` respective `ORDER_AFTER`
it cannot introduce a dependency loop; only when a class uses both we have
to do an exhaustive search of the dependent nodes. This search touches only
a few nodes instead of the whole graph and is only triggered for very few
service classes.
Summon has been creating an independent toposort of only the required
classes anyway, so we use this for deriving ordering indices for filter
chains from now on—this also allows simpler extension, modification of the
filter order (e.g. ``-index`` orders in reverse).
Methods for determining transitive dependency (and independency) have been
added to the service classes:
* :meth:`aioxmpp.Service.orders_after`,
* :meth:`aioxmpp.Service.orders_after_any`,
* :meth:`aioxmpp.Service.independent_from`.
These search the class graph and are therefore not efficient (and the
results may change when new classes are defined).
Tests should always prefer to test the declared attributes when checking for
correct dependencies.
* :func:`aioxmpp.make_security_layer` now binds the default for the ssl context
factory early to :func:`aioxmpp.security_layer.default_ssl_context`. This
means that you can not monkey-patch
:func:`aioxmpp.security_layer.default_ssl_context` and have your changes
apply to all security layers anymore. Since this behaviour was never
documented or intended, there is no transition period for this.
* :meth:`aioxmpp.xso.XSO.unparse_to_sax` was renamed to
:meth:`~aioxmpp.xso.XSO.xso_serialise_to_sax`.
Minor features and bug fixes
----------------------------
* Support for servers which send a :xep:`198` Stream Management counter in
resumption errors. This allows us to know precisely which stanzas were (not)
received by the server and thus improves accuracy of the stanza token state.
Stanzas which are acknowledged in this way by a server enter the
:attr:`~aioxmpp.stream.StanzaState.ACKED` state as normal. Stanzas which are
not covered by the counter enter
:attr:`~aioxmpp.stream.StanzaState.DISCONNECTED` state instead of
:attr:`~aioxmpp.stream.StanzaState.SENT_WITHOUT_SM`, since the stream knows
for sure that the stanza has not been received by the server.
This only works if the server provides a counter value on failure; if the
counter value is not provided, sent stanzas which were not acked during the
previous connection will enter
:attr:`~aioxmpp.stream.StanzaState.SENT_WITHOUT_SM` state as previously.
* :mod:`aioxmpp.forms` will not complain anymore if multiple ````
elements in a list-single/list-multi are lacking a label. It is recommended
that you default the label to the option value in such a case.
(Note that it already has been possible that *one* label was absent (i.e.
:data:`None`). This just allows more than one label to be absent.)
* :class:`aioxmpp.xso.ChildTextMap` can now also be constructed from a
tag, an appropriate XSO is then constructed on the fly.
* :meth:`aioxmpp.stream.StanzaStream.register_iq_request_handler`
and :func:`aioxmpp.service.iq_handler` now
support a keyword argument `with_send_reply` which makes them pass
an additional argument to the handler, which is a function that can be
used to enqueue the reply to the IQ before the handler has returned.
This allows sequencing other actions after the reply has been sent.
* :mod:`aioxmpp.hashes` now supports the `hashes-used` element and has a
service that handles registering the disco features and can determine
which hash functions are supported by us and another entity.
* Moved :class:`aioxmpp.protocol.AlivenessMonitor` to
:class:`aioxmpp.utils.AlivenessMonitor` for easier reuse.
* Extract :func:`aioxmpp.ping.ping` from :meth:`aioxmpp.PingService.ping`.
* :class:`aioxmpp.utils.proxy_property` for easier use of composed classes over
inherited classes.
* :class:`aioxmpp.xso.ChildValue` as a natural extension of
:class:`aioxmpp.xso.ChildValueList` and others.
* :func:`aioxmpp.make_security_layer` now supports the `ssl_context_factory`
argument which is already known from the (deprecated)
:func:`aioxmpp.security_layer.tls_with_password_based_authentication`.
It allows application code to pass a factory to create the SSL context
instead of defaulting to the SSL context provided by aioxmpp.
* Fix incorrect parsing of :xep:`198` location specifier. We always required a
port number, while the standards allows omit the port number.
* Fix incorrect serialisation of nested namespace declarations for the same URI.
One such occurrence is often encountered when using the
``<{urn:xmpp:forward:0}forwarded/>`` element (see
:class:`aioxmpp.misc.Forwarded`). It can host a ``<{jabber:client}message/>``.
Since we declare all namespaces of XSOs as prefixless, the nested message needs
to re-declare its prefix. Due to incorrect handling of namespace prefix
rebinding in :class:`aioxmpp.xml.XMPPXMLGenerator`, that re-declaration is not
emitted, leading to incorrect output.
This was reported in
`GitHub Issue #295 `_ by
`@oxoWrk `_.
* Fix assignment of enumeration members to descriptors using
:class:`aioxmpp.xso.EnumCDataType` with `allow_coerce` set to true but
`deprecate_coerce` set to false.
.. _api-changelog-0.10:
Version 0.10
============
New XEP implementations
-----------------------
* :mod:`aioxmpp.version` (:xep:`92`): Support for publishing the software
version of the client and accessing version information of other entities.
* :mod:`aioxmpp.mdr` (:xep:`184`): A tracking implementation (see
:mod:`aioxmpp.tracking`) which uses :xep:`184` Message Delivery Receipts.
* :mod:`aioxmpp.ibr` (:xep:`77`): Support for registering new accounts,
changing the password and deleting an account (via the non-data-form flow).
Contributed by `Sergio Alemany `_.
* :mod:`aioxmpp.httpupload` (:xep:`363`): Support for requesting an upload slot
(the actual uploading via HTTP is out of scope for this project, but look at
the ``upload.py`` example which uses :mod:`aiohttp`).
* :mod:`aioxmpp.misc` gained support for:
* parts of the :xep:`66` schema
* the :xep:`333` schema
* the ```` element of :xep:`379`
* Be robust against invalid IQ stanzas.
New major features
------------------
* *Improved timeout handling*: Before 0.10, there was an extremely simple
timeout logic: the :class:`aioxmpp.stream.StanzaStream` would send a ping of
some kind and expect a reply to that ping back within a certain timeframe. If
no reply *to that ping* was received within that timeframe, the stream would
be considered dead and it would be aborted.
The new timeout handling does not require that *a reply* is received; instead,
the stream is considered live as long as data is coming in, irrespective of
the latency. Only if no data has been received for a configurable time (
:attr:`aioxmpp.streams.StanzaStream.soft_timeout`), a ping is sent. New data
has to be received within :attr:`aioxmpp.streams.StanzaStream.round_trip_time`
after the ping has been sent (but it does not need to necessarily be a reply
to that ping).
* *Strict Ordering of Stanzas*: It is now possible to make use of the ordering
guarantee on XMPP XML streams for IQ handling. For this to work, normal
functions returning an awaitable are used instead of coroutines. This is
needed to prevent any possible ambiguity as to when coroutines handling IQ
requests are scheduled with respect to other IQ handler coroutines and other
stanza processing.
The following changes make this possible:
* Support for passing a function returning an awaitable as callback to
:meth:`aioxmpp.stream.StanzaStream.register_iq_request_coro`. In contrast
to coroutines, a callback function can exploit the strong ordering guarantee
of the XMPP XML Stream.
* Support for passing a callback function to
:meth:`aioxmpp.stream.StanzaStream.send` which is invoked on responses to an
IQ request sent through :meth:`~aioxmpp.stream.StanzaStream.send`. In
contrast to awaiting the result of
:meth:`~aioxmpp.stream.StanzaStream.send`, the callback can exploit the
strong ordering guarantee of the XMPP XML Stream.
* The :func:`aioxmpp.service.iq_handler` decorator function now allows normal
functions to be decorated (in addition to coroutine functions).
* Add `cb` argument to :func:`aioxmpp.protocol.send_and_wait_for` to allow to
act synchronously on the response. This is needed for transactional things
like stream management.
* *Consistent Member Argument for*
:meth:`~aioxmpp.im.conversation.AbstractConversation.on_message`:
The :meth:`aioxmpp.muc.Room.on_message` now always have a non-:data:`None`
`member` argument.
Please see the documentation of the event for some caveats of this `member`
argument as well as the rationale.
.. note::
Prosody ≤ 0.9.12 (for the 0.9 branch) and ≤ 0.10.0 (for the 0.10
branch) are affected by `Prosody issue #1053
`_.
This means that by itself, :class:`aioxmpp.muc.Room` cannot detect that
history replay is over and will stay in the history replay state forever.
However, two workarounds help with that: once the first live message is
or the first presence update is received, the :class:`~aioxmpp.muc.Room`
will assume a buggy server and transition to
:attr:`~aioxmpp.muc.RoomState.ACTIVE` state.
These workarounds are not perfect; in particular it is possible that the
first message workaround is defeated if a client includes a ````
into that message.
Until either a fixed version of Prosody is used or the workarounds take
effect, the following issues will be observed:
* :attr:`aioxmpp.muc.Occupant.uid` will not be useful in any way (but also
not harmful, security-wise).
* :meth:`aioxmpp.muc.Room.on_message` may receive `member` arguments which
are not part of the :attr:`aioxmpp.muc.Room.members` and which may also
lack other information (such as bare JIDs).
* :attr:`aioxmpp.muc.Room.muc_state` will not reach the
:attr:`aioxmpp.muc.RoomState.ACTIVE` state.
Applications which support e.g. :xep:`85` (Chat State Notifications) may
use a chat state notification (for example, active or inactive) to cause
a message to be received from the MUC, forcing the transition to
:attr:`~aioxmpp.muc.RoomState.ACTIVE` state.
This comes together with the new :attr:`aioxmpp.muc.Room.muc_state` attribute
which indicates the current local state of the room. See
:class:`aioxmpp.muc.RoomState`.
* *Recognizability of Occupants across Rejoins/Reboots*: The
:attr:`aioxmpp.im.conversation.AbstractConversationMember.uid`
attribute holds a (reasonably) unique string identifying the occupant. If
the :attr:`~aioxmpp.im.conversation.AbstractConversationMember.uid` of two
member objects compares equal, an application can be reasonably sure that
the two members refer to the same identity. If the UIDs of two members are
*not* equal, the application can be *sure* that the two members do not have
the same identity. This can be used for permission checks e.g. in the context
of Last Message Correction or similar applications.
* *Improved handling of pre-connection stanzas*:
The API for sending stanzas now lives at the :class:`aioxmpp.Client` as
:meth:`aioxmpp.Client.send` and :meth:`aioxmpp.Client.enqueue`. In addition,
:meth:`~aioxmpp.Client.send`\ -ing a stanza will block until the client has
a valid stream. Attempting to :meth:`~aioxmpp.Client.enqueue` a stanza while
the client does not have a valid stream raises a :class:`ConnectionError`.
A valid stream is either an actually connected stream or a suspended stream
with support for :xep:`198` resumption.
This prevents attempting to send stanzas over a stream which is not ready
yet. In the worst case, this can cause various errors if the stanza is then
effectively sent before resource binding has taken place.
* *Invitations*: :mod:`aioxmpp.muc` now supports sending invitations (via
:meth:`aioxmpp.muc.Room.invite`) and receiving invitations (via
:meth:`aioxmpp.MUCClient.on_muc_invitation`). The interface for
:meth:`aioxmpp.im.conversation.AbstractConversation.invite` has been reworked.
* *Service Members*:
:class:`aioxmpp.im.conversation.AbstractConversation`\ s can now have a
:class:`aioxmpp.im.conversation.AbstractConversationMember` representing the
conversation service itself inside that conversation (see
:term:`Service Member`).
The primary use is to represent messages originating from a :xep:`45` room
itself (on the protocol level, those messages have the bare JID of the room
as :attr:`~aioxmpp.Message.from`).
The service member of each conversation (if it is defined), is never contained
in the :attr:`aioxmpp.im.conversation.AbstractConversation.members` and
available at
:attr:`~aioxmpp.im.conversation.AbstractConversation.service_member`.
* *Better Child Element Enumerations*:
The :class:`aioxmpp.xso.XSOEnumMixin` is a mixin which can be used with
:class:`enum.Enum` to create an enumeration where each enumeration member has
its own XSO *class*.
This is useful for e.g. error conditions where a defined set of children
exists, but :class:`aioxmpp.xso.ChildTag` with an enumeration isn’t
appropriate because the child XSOs may have additional data. Refer to the
docs for more details.
* *Error Condition Data*:
The representation of XMPP error conditions on the XSO level has been
reworked. This is to support error conditions which have a data payload
(most importantly :attr:`aioxmpp.ErrorCondition.GONE`).
The entire error condition XSO is now available on both
:class:`aioxmpp.errors.XMPPError` (as
:attr:`~aioxmpp.errors.XMPPError.condition_obj`) exceptions and
:class:`aioxmpp.stanza.Error` payloads (as
:attr:`~aioxmpp.stanza.Error.condition_obj`).
For this change, the following subchanges are relevant:
* The constructors of :class:`aioxmpp.stanza.Error` and
:class:`aioxmpp.errors.XMPPError` (and subclasses) now accept either a
member of the :class:`aioxmpp.ErrorCondition` enumeration or an instance of
the respective XSO. This allows to attach additional data to error
conditions which support this, such as the
:attr:`aioxmpp.ErrorCondition.GONE` error.
* :attr:`aioxmpp.errors.XMPPError.application_defined_condition` is now
attached to :attr:`aioxmpp.stanza.Error.application_condition` when
:meth:`aioxmpp.stanza.Error.from_exception` is used.
Please see the breaking changes below for how to handle the transition from
namespace-name tuples to enumeration members.
New examples
------------
* ``upload.py``: uses :class:`aioxmpp.httpupload` and :class:`aiohttp` to upload
any file to an HTTP service offered by the XMPP server, if the server
supports the feature.
* ``register.py``: Register an account at an XMPP server which offers classic
:xep:`77` In-Band Registration.
Breaking changes
----------------
* Converted stanza and stream error conditions
to enumerations based on :class:`aioxmpp.xso.XSOEnumMixin`.
This is similar to the transition in the 0.7 release. The following
attributes, methods and constructors now expect enumeration members instead
of tuples:
* :class:`aioxmpp.stanza.Error`, the `condition` argument
* :attr:`aioxmpp.stanza.Error.condition`
* :attr:`aioxmpp.nonza.StreamError.condition`
* :class:`aioxmpp.errors.XMPPError` (and its subclasses), the `condition`
argument
* :attr:`aioxmpp.errors.XMPPError.condition`
To simplify the transition, the enumerations will compare equal to the
equivalent tuples until the release of 1.0.
The affected code locations can be found with the
``utils/find-v0.10-type-transition.sh`` script. It finds all tuples which
form error conditions. In addition, :class:`DeprecationWarning` type warnings
are emitted in the following cases:
* Enumeration member compared to tuple
* Tuple assigned to attribute or passed to method where an enumeration member
is expected
To make those warnings fatal, use the following code at the start of your
application::
import warnings
warnings.filterwarnings(
# make the warnings fatal
"error",
# match only deprecation warnings
category=DeprecationWarning,
# match only warnings concerning the ErrorCondition and
# StreamErrorCondition enumerations
message=".+(Stream)?ErrorCondition",
)
* Split :class:`aioxmpp.xso.AbstractType` into
:class:`aioxmpp.xso.AbstractCDataType` (for which the
:class:`aioxmpp.xso.AbstractType` was originally intended) and
:class:`aioxmpp.xso.AbstractElementType` (which it has become through organic
growth). This split serves the maintainability of the code and offers
opportunities for better error detection.
* :meth:`aioxmpp.BookmarkService.get_bookmarks`
now returns a list instead of a :class:`aioxmpp.bookmarks.Storage`
and :meth:`aioxmpp.BookmarkService.set_bookmarks` now accepts a
list. The list returned by the get method and its elements *must
not* be modified.
* Make :meth:`aioxmpp.muc.Room.send_message_tracked` a normal method instead
of a coroutine (it was never intended to be a coroutine).
* Specify :meth:`aioxmpp.im.conversation.AbstractConversation.on_enter` and
:meth:`~aioxmpp.im.conversation.AbstractConversation.on_failure` events and
implement emission of those for the existing conversation implementations.
* Specify that :term:`Conversation Services ` must
provide a non-coroutine method to start a conversation. Asynchronous parts
have to happen in the background. To await the completion of the
initialisation of the conversation, use
:func:`aioxmpp.callbacks.first_signal` as described in
:meth:`aioxmpp.im.conversation.AbstractConversation.on_enter`.
* Make :meth:`aioxmpp.im.p2p.Service.get_conversation` a normal method.
* :meth:`aioxmpp.muc.Room.send_message` is not a
coroutine anymore, but it returns an awaitable; this means that in most
cases, this should not break.
:meth:`~aioxmpp.muc.Room.send_message` was a coroutine by accident; it should
never have been that, according to the specification in
:meth:`aioxmpp.im.conversation.AbstractConversation.send_message`.
* Since multiple ```` elements can occur in a
stanza, :attr:`aioxmpp.Message.xep0203_delay` is now a list instead of a
single :class:`aioxmpp.misc.Delay` object. Sorry for the inconvenience.
* The type of the value of
:class:`aioxmpp.xso.Collector` descriptors was changed from
:class:`list` to :class:`lxml.etree.Element`.
* Assignment to :class:`aioxmpp.xso.Collector` descriptors is now forbidden.
Instead, you should use ``some_xso.collector_attr[:] = items`` or a similar
syntax.
* :meth:`aioxmpp.muc.Room.on_enter` does not receive any
arguments anymore to comply with the updated
:class:`aioxmpp.im.AbstractConversation` spec. The
:meth:`aioxmpp.muc.Room.on_muc_enter` event provides the arguments
:meth:`~aioxmpp.muc.Room.on_enter` received before and fires right after
:meth:`~aioxmpp.muc.Room.on_enter`.
As a workaround (if you need the arguments), you can test whether the
:meth:`~aioxmpp.muc.Room.on_muc_enter` exists on a
:class:`~aioxmpp.muc.Room`. If it does, connect to it, otherwise connect to
:meth:`~aioxmpp.muc.Room.on_enter`.
If you don’t need the arguments, make your :meth:`~aioxmpp.muc.Room.on_enter`
handlers accept ``*args``.
* :meth:`aioxmpp.AvatarService.get_avatar_metadata`
now returns a list instead of a mapping from MIME types to lists of
descriptors.
* Replaced the
:attr:`aioxmpp.stream.StanzaStream.ping_interval` and
:attr:`~aioxmpp.stream.StanzaStream.ping_opportunistic_interval` attributes
with a new ping implementation.
It is described in the :ref:`aioxmpp.stream.General Information.Timeouts`
section in :mod:`aioxmpp.stream`.
* :meth:`aioxmpp.connector.BaseConnector.connect`
implementations are expected to set the
:attr:`aioxmpp.protocol.XMLStream.deadtime_hard_limit` to the
value of their `negotiation_timeout` argument and use this mechanism to handle
any stream-level timeouts.
* :attr:`aioxmpp.muc.Occupant.direct_jid`
is now always a bare jid. This implies that the resource part of a
jid passed in by a muc member item now is always ignored. Passing a
full jid to the constructor now raises a :class:`ValueError`.
Minor features and bug fixes
----------------------------
* Make :mod:`aioopenssl` a mandatory dependency.
* Replace :mod:`orderedset` with :mod:`sortedcollections`.
* Emit :meth:`aioxmpp.im.conversation.AbstractConversation.on_message` for
MUC messages sent via :meth:`~aioxmpp.muc.Room.send_message_tracked`.
* Add ``tracker`` argument to
:meth:`aioxmpp.im.conversation.AbstractConversation.on_message`. It carries
a :class:`aioxmpp.tracking.MessageTracker` for sent messages (including
those sent by other resources of the account in the same conversation).
* Fix (harmless) traceback in logs which could occur when using
:meth:`aioxmpp.muc.Room.send_message_tracked`.
* Fix :func:`aioxmpp.service.is_depsignal_handler` and
:func:`~aioxmpp.service.is_attrsignal_handler` when used with ``defer=True``.
* You can now register custom bookmark classes with
:func:`aioxmpp.bookmarks.as_bookmark_class`. The bookmark classes
must subclass the ABC :class:`aioxmpp.bookmarks.Bookmark`.
* Implement :func:`aioxmpp.callbacks.first_signal`.
* Fixed duplicate emission of
:meth:`~aioxmpp.im.conversation.AbstractConversation.on_message` events
for untracked (sent through :meth:`aioxmpp.muc.Room.send_message`) MUC
messages.
* Re-read the nameserver config if :class:`dns.resolver.NoNameservers` is
raised during a query using the thread-local global resolver (the default).
The resolver config is only reloaded up to once for each query; any further
errors are treated as authoritative / related to the zone.
* Add :meth:`aioxmpp.protocol.XMLStream.mute` context manager to suppress debug
logging of stream contents.
* Exclude authentication information sent during SASL.
* The new :meth:`aioxmpp.structs.LanguageMap.any` method allows to obtain an
arbitrary element from the language map.
* New `erroneous_as_absent` argument to :class:`aioxmpp.xso.Attr`,
:class:`~aioxmpp.xso.Text` and :class:`~aioxmpp.xso.ChildText`. See the
documentation of :class:`~aioxmpp.xso.Attr` for details.
* Treat absent ``@type`` XML attribute on message stanzas as
:class:`aioxmpp.MessageType.NORMAL`, as specified in :rfc:`6121`,
section 5.2.2.
* Treat empty ```` XML child on presence stanzas like absent
````. This is not legal as per :rfc:`6120`, but apparently there are
some broken implementations out there.
Not having this workaround leads to being unable to receive presence stanzas
from those entities, which is rather unfortunate.
* :func:`aioxmpp.service.iq_handler` now checks that its payload class is in
fact registered as IQ payload and raises :class:`ValueError` if not.
* :func:`aioxmpp.node.discover_connectors` will now continue of only one of the
two SRV lookups fails with the DNSPython :class:`dns.resolver.NoNameservers`
exception; this case might still indicate a configuration issue (so we log
it), but since we actually got a useful result on the other query, we can
still continue.
* :func:`aioxmpp.node.discover_connectors` now uses a proper fully-qualified
domain name (including the trailing dot) for DNS queries to avoid improper
fallback to locally configured search domains.
* Ignore presence stanzas from the bare JID of a joined MUC, even if they
contain a MUC user tag. A functional MUC should never emit this.
* We now will always attempt STARTTLS negotiation if
:attr:`aioxmpp.security_layer.SecurityLayer.tls_required` is true, even if
the server does not advertise a STARTTLS stream feature. This is because we
have nothing to lose, and it may mitigate some types of STARTTLS stripping
attacks.
* Compatibility fixes for ejabberd (cf.
`ejabberd#2287 `_
and `ejabberd#2288 `_).
* Harden MUC implementation against incomplete presence stanzas.
* Fix a race condition where stream management handlers would be installed too
late on the XML stream, leading it to be closed with an
``unsupported-stanza-type`` because :mod:`aioxmpp` failed to interpret SM
requests.
* Support for escaping additional characters as entities when writing XML, see
the `additional_escapes` argument to :class:`aioxmpp.xml.XMPPXMLGenerator`.
* Support for the new :xep:`45` 1.30 status code for kicks due to errors.
See :attr:`aioxmpp.muc.LeaveMode.ERROR`.
* Minor compatibility fixes for :xep:`153` vcard-based avatar support.
* Add a global IM :meth:`aioxmpp.im.service.Conversation.on_message` event. This
aggregates message events from all conversations.
This can be used by applications which want to perform central processing of
all IM messages, for example for logging purposes.
:class:`aioxmpp.im.service.Conversation` handles the lifecycle of event
listeners to the individual conversations, which takes some burden off of the
application.
* Fix a bug where monkey-patched :class:`aioxmpp.xso.ChildFlag` descriptors
would not be picked up by the XSO handling code.
* Make sure that the message ID is set before the
:attr:`aioxmpp.im.conversation.AbstractConversation.on_message` event is
emitted from :class:`aioxmpp.im.p2p.Conversation` objects.
* Ensure that all
:attr:`aioxmpp.MessageType.CHAT`/:attr:`~aioxmpp.MessageType.NORMAL` messages
are forwarded to the respective :class:`aioxmpp.im.p2p.Conversation` if it
exists.
(Previously, only messages with a non-empty :attr:`aioxmpp.Message.body`
would be forwarded.)
This is needed for e.g. Chat Markers.
* Ensure that Message Carbons are
re-:meth:`aioxmpp.carbons.CarbonsClient.enable`\ -d after failed stream
resumption. Thanks, Ge0rG.
* Fix :rfc:`6121` violation: the default of the ``@subscription`` attribute of
roster items is ``"none"``. :mod:`aioxmpp` treated an absent attribute as
fatal.
* Pass pre-stream-features exception down to stream feature listeners. This
fixes hangs on errors before the stream features are received. This can
happen with misconfigured SRV records or lack of ALPN support in a :xep:`368`
setting. Thanks to Travis Burtrum for providing a test setup for hunting this
down.
* Set ALPN to ``xmpp-client`` by default. This is useful for :xep:`368`
deployments.
* Fix handling of SRV records with equal priority, weight, hostname and port.
* Support for ```` element in :rfc:`3921` ```` negotiation
feature; the feature is not needed with modern servers, but since legacy
clients require it, they still announce it. The feature introduces a new
round-trip for no gain. An `rfc-draft by Dave Cridland
`_ standardises
the ```` element which allows a server to tell the client that it
doesn’t require the session negotiation step. :mod:`aioxmpp` now understands
this and will skip that step, saving a round-trip with most modern servers.
* :mod:`aioxmpp.tracking` now allows some state transitions out of the
:attr:`aioxmpp.tracking.MessageState.ERROR` state. See the documentation there
for details.
* Fix a bug in :meth:`aioxmpp.JID.fromstr` which would incorrectly parse and
then reject some valid JIDs.
* Add :meth:`aioxmpp.DiscoClient.flush_cache` allowing to flush the cached
entries.
* Add :meth:`aioxmpp.disco.Node.set_identity_names`. This is much more
convenient than adding a dummy identity, removing the existing identity,
re-adding the identity with new names and then removing the dummy identity.
* Remove restriction on data form types (not to be confused with
``FORM_TYPE``) when instantiating a form with
:meth:`aioxmpp.forms.Form.from_xso`.
* Fix an issue which prevented single-valued form fields from being rendered
into XSOs if no value had been set (but a default was given).
* Ensure that forms with :attr:`aioxmpp.forms.Form.FORM_TYPE` attribute render
a proper :xep:`68` ``FORM_TYPE`` field.
* Allow unset field type in data forms. This may seem weird, but unfortunately
it is widespread practice. In some data form types, omitting the field type
is common (including it is merely a MAY in the XEP), and even in the most
strict case it is only a SHOULD.
Relying on the field type to be present is thus a non-starter.
* Some data form classes were added:
* :class:`aioxmpp.muc.InfoForm`
* :class:`aioxmpp.muc.VoiceRequestForm`
* Support for answering requests for voice/role change in MUCs (cf.
`XEP-0045 §8.6 Approving Voice Requests `_). See
:meth:`aioxmpp.muc.Room.on_muc_role_request` for details.
* Support for unwrapped unknown values in :class:`aioxmpp.xso.EnumCDataType`.
This can be used with :class:`enum.IntEnum` for fun and profit.
* The status codes for :mod:`aioxmpp.muc` events are now an enumeration (see
:class:`aioxmpp.muc.StatusCode`). The status codes are now also available
on the following events: :meth:`aioxmpp.muc.Room.on_muc_enter`,
:meth:`~aioxmpp.muc.Room.on_exit`,
:meth:`~aioxmpp.muc.Room.on_leave`, :meth:`~aioxmpp.muc.Room.on_join`,
:meth:`~aioxmpp.muc.Room.on_muc_role_changed`, and
:meth:`~aioxmpp.muc.Room.on_muc_affiliation_changed`.
* The :meth:`aioxmpp.im.conversation.AbstractConversation.invite` was
overhauled and improved.
* :class:`aioxmpp.PEPClient` now depends on :class:`aioxmpp.EntityCapsService`.
This prevents a common mistake of loading :class:`~aioxmpp.PEPClient` without
:class:`~aioxmpp.EntityCapsService`, which prevents PEP auto-subscription
from working.
* Handle :class:`ValueError` raised by :mod:`aiosasl` when the credentials are
malformed.
* Fix exception when attempting to leave a :class:`aioxmpp.im.p2p.Conversation`.
Deprecations
------------
* The above split of :class:`aioxmpp.xso.AbstractType` also caused a split of
:class:`aioxmpp.xso.EnumType` into :class:`aioxmpp.xso.EnumCDataType` and
:class:`aioxmpp.xso.EnumElementType`. :func:`aioxmpp.xso.EnumType` is now a
function which transparently creates the correct class. Use of that function
is deprecated and you should upgrade your code to use one of the two named
classes explicitly.
* The name :meth:`aioxmpp.stream.StanzaStream.register_iq_request_coro` is
deprecated in favour of
:meth:`~aioxmpp.stream.StanzaStream.register_iq_request_handler`.
The old alias persists, but will be removed with the release of 1.0. Using
the old alias emits a warning.
Likewise, :meth:`~aioxmpp.stream.StanzaStream.unregister_iq_request_coro` was
renamed to :meth:`~aioxmpp.stream.StanzaStream.unregister_iq_request_handler`.
* :meth:`aioxmpp.stream.StanzaStream.enqueue` and
:meth:`aioxmpp.stream.StanzaStream.send` were moved to the client as
:meth:`aioxmpp.Client.enqueue` and :meth:`aioxmpp.Client.send`.
The old names are deprecated, but aliases are provided until version 1.0.
* The `negotiation_timeout` argument for
:func:`aioxmpp.security_layer.negotiate_sasl` has been deprecated in favour
of :class:`aioxmpp.protocol.XMLStream`\ -level handling of timeouts.
This means that the respective timeouts need to be configured on the XML
stream if they are to be used (the normal connection setup takes care of
that).
* The use of namespace-name tuples for error conditions has been deprecated
(see the breaking changes).
Version 0.10.1
--------------
* Fix handling of IQ stanzas without ID and IQ stanzas of type
:attr:`aioxmpp.IQType.ERROR` without error payload.
Both are now treated like any other unparseable stanza.
Version 0.10.2
--------------
* Make compatible with Python 3.7.
Version 0.10.3
--------------
* Fix incorrect error handling in :mod:`aioxmpp.xso` when a supressing
:meth:`aioxmpp.xso.XSO.xso_error_handler` is in use.
Under certain circumstances, it is possible that the handling of supressed
error causes another error later on because the parsing stack mis-counts the
depth in which it is inside the XML tree. This makes elements appear in the
wrong place, typically leading to further errors.
In the worst case, using a supressing
:meth:`~aioxmpp.xso.XSO.xso_error_handler` in specific circumstances can be
vulnerable to denial of service and data injection into the XML stream.
(A CVE will be allocated for this.)
Version 0.10.4
--------------
* Fix incorrect parsing of :xep:`198` location specifier. We always required a
port number, while the standards allows omit the port number.
.. _api-changelog-0.9:
Version 0.9
===========
New XEP implementations
-----------------------
* :mod:`aioxmpp.bookmarks` (:xep:`48`): Support for accessing bookmark storage
(currently only from Private XML storage).
* :mod:`aioxmpp.private_xml` (:xep:`49`): Support for accessing a server-side
account-private XML storage.
* :mod:`aioxmpp.avatar` (:xep:`84`): Support for retrieving avatars,
notifications for changed avatars in contacts and setting the avatar of the
account itself.
* :mod:`aioxmpp.pep` (:xep:`163`): Support for making use of the Personal
Eventing Protocol, a versatile protocol used to store and publish
account-specific information such as Avatars, OMEMO keys, etc. throughout the
XMPP network.
* :mod:`aioxmpp.blocking` (:xep:`191`): Support for blocking contacts on the
server-side.
* :mod:`aioxmpp.ping` (:xep:`199`): XMPP Ping has been used internally since
the very beginning (if Stream Management is not supported), but now there’s
also a service for applications to use.
* :mod:`aioxmpp.carbons` (:xep:`280`): Support for receiving carbon-copies of
messages sent and received by other resources.
* :mod:`aioxmpp.entitycaps` (:xep:`390`): Support for the new Entity
Capabilities 2.0 protocol was added.
Most of these have been contributed by Sebastian Riese. Thanks for that!
New major features
------------------
* :mod:`aioxmpp.im` is a new subpackage which provides Instant Messaging
services. It is still highly experimental, and feedback on the API is highly
appreciated.
The idea is to provide a unified interface to the different instant messaging
transports, such as direct one-on-one chat, Multi-User Chats (:xep:`45`) and
the soon-to-come Mediated Information Exchange (:xep:`369`).
Applications shall be able to use the interface without knowing the details
of the transport; features such as message delivery receipts and message
carbons shall work transparently.
In the course of this (see below), some breaking changes had to be made, but
we think that the gain is worth the damage.
For an introduction in those features, read the documentation of the
:mod:`aioxmpp.im` subpackage. The examples using IM features have been
updated accordingly.
* The distribution of received presence and message stanzas has been reworked
(to help with :mod:`aioxmpp.im`, which needs a very different model of
message distribution than the traditional "register a handler for a sender
and type"). The classic registration functions have been deprecated (see
below) and were replaced by simple dispatcher services provided in
:mod:`aioxmpp.dispatcher`.
New examples
------------
* ``carbons_sniffer.py``: Show a log of all messages received and sent by other
resources of the same account.
* ``set_avatar.py``: Change the avatar of the account.
* ``retrieve_avatar.py``: Retrieve the avatar of a member of the XMPP network
(sufficient permissions required, normally a roster subscription is enough).
Breaking changes
----------------
* Classes using :func:`aioxmpp.service.message_handler` or
:func:`aioxmpp.service.presence_handler` have to declare
:class:`aioxmpp.dispatcher.SimpleMessageDispatcher` or
:class:`aioxmpp.dispatcher.SimplePresenceDispatcher` (respectively) in their
dependencies.
A backward-compatible way to do so is to declare the dependency
conditionally::
class FooService(aioxmpp.service.Service):
ORDER_AFTER = []
try:
import aioxmpp.dispatcher
except ImportError:
pass
else:
ORDER_AFTER.append(
aioxmpp.dispatcher.SimpleMessageDispatcher
)
* :class:`aioxmpp.stream.Filter` got renamed to
:class:`aioxmpp.callbacks.Filter`. This should normally not affect your code.
* Re-write of :mod:`aioxmpp.tracking` for :mod:`aioxmpp.im`. Sorry. But the new
API is more clearly defined and more correct. The (ab-)use of
:class:`aioxmpp.statemachine.OrderedStateMachine` never really worked
anyways.
* Re-design of interface to :mod:`aioxmpp.muc`. This is unfortunate, but we
did not see a way to reasonably provide backward-compatibility while still
allowing for a clean integration with :mod:`aioxmpp.im`.
* Re-design of :class:`aioxmpp.entitycaps` to support
:xep:`390`. The interface of the :class:`aioxmpp.entitycaps.Cache` class has
been redesigned and some internal classes and functions have been renamed.
* :attr:`aioxmpp.IQ.payload`,
:attr:`aioxmpp.pubsub.xso.Item.registered_payload` and
:attr:`aioxmpp.pubsub.xso.EventItem.registered_payload` now strictly check
the type of objects assigned. The classes of those objects *must* be
registered with :meth:`aioxmpp.IQ.as_payload_class` or
:func:`aioxmpp.pubsub.xso.as_payload_class`, respectively.
Technically, that requirement existed always as soon as one wanted to be able
to *receive* those payloads: otherwise, one would simply not receive the
payload, but an exception or empty object instead. By enforcing this
requirement also for sending, we hope to improve the debugability of these
issues.
* The descriptors and decorators for
:class:`aioxmpp.service.Service` subclasses are now initialised in the order
they are declared.
This should normally not affect you, there are only very specific
corner-cases where it makes a difference.
Minor features and bug fixes
----------------------------
* Handle local serialisation issues more gracefully. Instead of sending a
half-serialised XSO down the stream and then raising an exception, leaving the
stream in an undefined state, XSOs are now serialised into a buffer (which is
re-used for performance when possible) and only if serialisation was
successful sent down the stream.
* Replaced the hack-ish use of generators for
:func:`aioxmpp.xml.write_xmlstream` with a proper class,
:class:`aioxmpp.xml.XMLStreamWriter`.
The generator blew up when we tried to exfiltrate exceptions from it. For the
curious and brave, see the ``bug/odd-exception-thing`` branch. I actually
suspect a CPython bug there, but I was unable to isolate a proper test case.
It only blows up in the end-to-end tests.
* :mod:`aioxmpp.dispatcher`: This is in connection with the :mod:`aioxmpp.im`
package
* :mod:`aioxmpp.misc` provides XSO definitions for two minor XMPP protocol
parts (:xep:`203`, :xep:`297`), which are however reused in some of the
protocols implemented in this release.
* :mod:`aioxmpp.hashes` (:xep:`300`): Friendly interface to the hash functions
and hash function names defined in :xep:`300`.
* :xep:`Stream Management <198>` counters now wrap around as unsigned
32 bit integers, as the standard specifies.
* :func:`aioxmpp.service.depsignal` now supports connecting to
:class:`aioxmpp.stream.StanzaStream` and :class:`aioxmpp.Client` signals.
* Unknown and unhandled IQ get/set payloads are now replied to with
```` instead of ````, as the
former is actually specified in :rfc:`6120` section 8.4.
* The :class:`aioxmpp.protocol.XMLStream` loggers for :class:`aioxmpp.Client`
objects are now a child of the client logger itself, and not at
``aioxmpp.XMLStream``.
* Fix bug in :class:`aioxmpp.EntityCapsService` rendering it useless for
providing caps hashes to other entities.
* Fix :meth:`aioxmpp.callbacks.AdHocSignal.future`, which was entirely unusable
before.
* :func:`aioxmpp.service.depfilter`: A decorator (similar to the
:func:`aioxmpp.service.depsignal` decorator) which allows to add a
:class:`aioxmpp.service.Service` method to a
:class:`aioxmpp.callbacks.Filter` chain.
* Fix :attr:`aioxmpp.RosterClient.groups` not being updated when items are
removed during initial roster update.
* The two signals :meth:`aioxmpp.RosterClient.on_group_added`,
:meth:`~aioxmpp.RosterClient.on_group_removed` were added, which allow to
track which groups exist in a roster at all (a group exists if there’s at
least one member).
* Roster pushes are now accepted also if the :attr:`~.StanzaBase.from_` is the
bare local JID instead of missing/empty (those are semantically equivalent).
* :class:`aioxmpp.disco.RegisteredFeature` and changes to
:class:`aioxmpp.disco.register_feature`. Effectively, attributes described by
:class:`~aioxmpp.disco.register_feature` now have an
:attr:`~aioxmpp.disco.RegisteredFeature.enabled` attribute which can be used
to temporarily or permanently disable the registration of the feature on a
service object.
* The :meth:`aioxmpp.disco.StaticNode.clone` method allows to copy another
:meth:`aioxmpp.disco.Node` as a :class:`aioxmpp.disco.StaticNode`.
* The :meth:`aioxmpp.disco.Node.as_info_xso` methdo creates a
:class:`aioxmpp.disco.xso.InfoQuery` object containing the features and
identities of the node.
* The `strict` argument was added to :class:`aioxmpp.xso.Child`. It allows to
enable strict type checking of the objects assigned to the descriptor. Only
those objects whose classes have been registered with the descriptor can be
assigned.
This helps with debugging issues for "extensible" descriptors such as the
:attr:`aioxmpp.IQ.payload` as described in the Breaking Changes section of
this release.
* :class:`aioxmpp.DiscoClient` now uses :class:`aioxmpp.cache.LRUDict`
for its internal caches to prevent memory exhaustion in long running
applications and/or with malicious peers.
* :meth:`aioxmpp.DiscoClient.query_info` now supports a `no_cache` argument
which prevents caching of the request and response.
* :func:`aioxmpp.service.attrsignal`: A decorator (similar to the
:func:`aioxmpp.service.depsignal` decorator) which allows to connect to a
signal on a descriptor.
* The `default` of XSO descriptors has incorrectly been passed through the
validator, despite the documentation saying otherwise. This has been fixed.
* :attr:`aioxmpp.Client.resumption_timeout`: Support for specifying the
lifetime of a Stream Management (:xep:`198`) session and disabling stream
resumption altogether. Thanks to `@jomag for bringing up the use-case
`_.
* Fix serialisation of :class:`aioxmpp.xso.Collector` descriptors.
* Make :class:`aioxmpp.xml.XMPPXMLGenerator` avoid the use of namespace
prefixes if a namespace is undeclared if possible.
* Attempt to reconnect if generic OpenSSL errors occur. Thanks to `@jomag for
reporting `_.
* The new :meth:`aioxmpp.stream.StanzaStream.on_message_received`,
:meth:`~aioxmpp.stream.StanzaStream.on_presence_received` signals
unconditionally fire when a message or presence is received. They are used
by the :mod:`aioxmpp.dispatcher` and :mod:`aioxmpp.im` implementations.
Deprecations
------------
* The following methods on :class:`aioxmpp.stream.StanzaStream`
have been deprecated and will be removed in 1.0:
* :meth:`~.StanzaStream.register_message_callback`
* :meth:`~.StanzaStream.unregister_message_callback`
* :meth:`~.StanzaStream.register_presence_callback`
* :meth:`~.StanzaStream.unregister_presence_callback`
The former two are replaced by the
:class:`aioxmpp.dispatcher.SimpleMessageDispatcher` service and the latter two
should be replaced by proper use of the :class:`aioxmpp.PresenceClient` or
by :class:`aioxmpp.dispatcher.SimplePresenceDispatcher` if the
:class:`~aioxmpp.PresenceClient` is not sufficient.
* :func:`aioxmpp.stream.stanza_filter` got renamed to
:meth:`aioxmpp.callbacks.Filter.context_register`.
Version 0.9.1
-------------
* *Slight Breaking change* (yes, I know!) to fix a crucial bug with Python
3.4.6. :func:`aioxmpp.node.discover_connectors` now takes a :class:`str`
argument instead of :class:`bytes` for the domain name. Passing a
:class:`bytes` will fail.
As this issue prohibited use with Python 3.4.6 under certain circumstances,
we had to make a slight breaking change in a minor release. We also consider
:func:`~aioxmpp.node.discover_connectors` to be sufficiently rarely useful
to warrant breaking compatibility here.
For the same reason, :func:`aioxmpp.network.lookup_srv` now returns
:class:`bytes` for hostnames instead of :class:`str`.
* Fix issues with different versions of :mod:`pyasn1`.
.. _api-changelog-0.8:
Version 0.8
===========
New XEP implementations
-----------------------
* :mod:`aioxmpp.adhoc` (:xep:`50`): Support for using Ad-Hoc commands;
publishing own Ad-Hoc commands for others to use is not supported yet.
New major features
------------------
* Services (see :mod:`aioxmpp.service`) are now even easier to write, using
the new :ref:`api-aioxmpp.service-decorators`. These allow automagically
registering methods as handlers or filters for stanzas and other often-used
things.
Existing services have been ported to this new system, and we recommend to
do the same with your own services!
* :mod:`aioxmpp` now supports end-to-end testing using an XMPP server (such as
`Prosody `_). For the crude details see
:mod:`aioxmpp.e2etest` and the :ref:`dg-end-to-end-tests` section in the
Developer Guide. The :mod:`aioxmpp.e2etest` API is still highly experimental
and should not be used outside of :mod:`aioxmpp`.
New examples
------------
* ``adhoc_browser``: A graphical tool to browse and execute Ad-Hoc Commands.
Requires PyQt5. Run ``make`` in the examples directory and start with
``python3 -m adhoc_browser``.
* ``entity_items.py``, ``entity_info.py``: Show service discovery info and items
for arbitrary JIDs.
* ``list_adhoc_commands.py``: List the Ad-Hoc commands offered by an entity.
Breaking changes
----------------
Changes to the connection procedure:
* If any of the connection errors encountered in
:meth:`aioxmpp.node.connect_xmlstream` is a
:class:`aioxmpp.errors.TLSFailure` *and all* other connection options also
failed, the :class:`~.errors.TLSFailure` is re-raised instead of a
:class:`aioxmpp.errors.MultiOSError` instance. This helps to prevent masking
of configuration problems.
* The change of :meth:`aioxmpp.node.connect_xmlstream` described above also
affects the behaviour of :class:`aioxmpp.Client`, as
:class:`~.errors.TLSFailure` errors are treated as critical (in contrast to
:class:`OSError` subclasses).
Changes in :class:`aioxmpp.Client` (formerly :class:`aioxmpp.AbstractClient`,
see in the deprecations below for the name change)
* The number of connection attempts made before the first connection is
successful is now bounded, configurable through the new parameter
`max_initial_attempts`. The default is at 4, which gives (together with the
default exponential backoff parameters) a minimum time of attempted
connections of about 5 seconds.
* :meth:`~.Client.on_stream_suspended` was added (this is not a breaking
change, but belongs to the :class:`aioxmpp.Client` changes discussed here).
* :meth:`~.Client.on_stream_destroyed` got a new argument `reason`
which gives the exception which caused the stream to be destroyed.
Other breaking changes:
* :attr:`aioxmpp.tracking.MessageState.UNKNOWN` renamed to
:attr:`~.MessageState.CLOSED`.
* :meth:`aioxmpp.disco.Node.iter_items`,
:meth:`~aioxmpp.disco.Node.iter_features` and
:meth:`~aioxmpp.disco.Node.iter_identities` now get the request stanza passed
as first argument.
* :attr:`aioxmpp.Presence.show` now uses the
:class:`aioxmpp.PresenceShow` enumeration. The breakage is similar to the
breakage in the 0.7 release; if I had thought of it at that time, I would have
made the change back then, but it was overlooked.
Again, a utility script (``find-v0.8-type-transitions.sh``) is provided which
helps finding locations of code which need changing. See the
:ref:`api-changelog-0.7` for details.
* Presence states with ``show`` set to
:attr:`~.PresenceShow.DND` now order highest (before,
:attr:`~.PresenceShow.DND` ordered lowest). The rationale is that if a user
indicates :attr:`~.PresenceShow.DND` state at one resource, one should
probably respect the Do-Not-Disturb request on all resources.
The following changes are not severe, but may still break code depending on how
it is used:
* :class:`aioxmpp.disco.Service` was split into
:class:`aioxmpp.DiscoClient` and :class:`aioxmpp.DiscoServer`.
If you need to be compatible with old versions, use code like this::
try:
from aioxmpp import DiscoClient, DiscoServer
except ImportError:
import aioxmpp.disco
DiscoClient = aioxmpp.disco.Service
DiscoServer = aioxmpp.disco.Service
* Type coercion in XSO descriptors now behaves differently. Previously,
:data:`None` was hard-coded to be exempt from type coercion; this allowed
*any* :class:`~.xso.Text`, :class:`~.xso.ChildText`, :class:`~.xso.Attr` and
other scalar descriptor to be assigned :data:`None`, unless a validator which
explicitly forbade that was installed. The use case was to have a default,
absence-indicating value which is outside the valid value range of the
``type_``.
This is now handled by exempting the ``default`` of the descriptor from type
coercion and thus allowing assignment of that default by default. The change
thus only affects descriptors which have a ``default`` other than
:data:`None` (which includes an unset default).
Minor features and bug fixes
----------------------------
* :class:`aioxmpp.stream.StanzaToken` objects are now :term:`awaitable`.
* :meth:`aioxmpp.stream.StanzaStream.send` introduced as method which can be
used to send arbitrary stanzas. See the docs there to observe the full
awesomeness.
* Improvement and fixes to :mod:`aioxmpp.muc`:
* Implemented :meth:`aioxmpp.muc.Room.request_voice`.
* Fix :meth:`aioxmpp.muc.Room.leave_and_wait` never returning.
* Do not emit :meth:`aioxmpp.muc.Room.on_join` when an unavailable presence
from an unknown occupant JID is received.
* Added context managers for registering a callable as stanza handler or filter
temporarily:
* :func:`aioxmpp.stream.iq_handler`,
* :func:`aioxmpp.stream.message_handler`,
* :func:`aioxmpp.stream.presence_handler`, and
* :func:`aioxmpp.stream.stanza_filter`.
* The :attr:`aioxmpp.service.Service.dependencies` attribute was added.
* Support for ANONYMOUS SASL mechanism. See :meth:`aioxmpp.security_layer.make`
for details (requires aiosasl 0.3+).
* Get rid of dependency on libxml2 development files. libxml2 itself is still
required, both directly and indirectly (through the lxml dependency).
* The :class:`aioxmpp.PresenceServer` service was introduced and the
:class:`aioxmpp.PresenceManagedClient` was re-implemented on top of that.
* Fix :exc:`AttributeError` being raised from ``state > None`` (and other
comparison operators), with ``state`` being a :class:`aioxmpp.PresenceState`
instance.
The more correct :exc:`TypeError` is now raised.
* The handling of stanzas with unparsable attributes and stanzas originating
from the clients bare JID (i.e. from the clients server on behalf on the
account) has improved.
* The examples now default to ``$XDG_CONFIG_HOME/aioxmpp-examples.ini`` for
configuration if it exists. (thanks, `@mcepl
`_).
Deprecations
------------
* Several classes were renamed:
* :class:`aioxmpp.node.AbstractClient` → :class:`aioxmpp.Client`
* :class:`aioxmpp.shim.Service` → :class:`aioxmpp.SHIMService`
* :class:`aioxmpp.muc.Service` → :class:`aioxmpp.MUCClient`
* :class:`aioxmpp.presence.Service` → :class:`aioxmpp.PresenceClient`
* :class:`aioxmpp.roster.Service` → :class:`aioxmpp.RosterClient`
* :class:`aioxmpp.entitycaps.Service` → :class:`aioxmpp.EntityCapsService`
* :class:`aioxmpp.pubsub.Service` → :class:`aioxmpp.PubSubClient`
The old names are still available until 1.0.
* :meth:`~.StanzaStream.send_and_wait_for_sent` deprecated in favour of
:meth:`~.StanzaStream.send`.
* :meth:`~.StanzaStream.send_iq_and_wait_for_reply` deprecated in favour of
:meth:`~.StanzaStream.send`.
* :meth:`~.StanzaStream.enqueue_stanza` is now called
:meth:`~aioxmpp.stream.StanzaStream.enqueue`.
* The `presence` argument to the constructor of and the
:attr:`~.UseConnected.presence` and :attr:`~.UseConnected.timeout` attributes
on :class:`aioxmpp.node.UseConnected` objects are deprecated.
See the respective documentation for details on the deprecation procedure.
.. _api-changelog-0.7:
Version 0.7
===========
* **License change**: As of version 0.7, :mod:`aioxmpp` is distributed under the
terms of the GNU Lesser General Public License version 3 or later (LGPLv3+).
The exact terms are, as usual, found by taking a look at ``COPYING.LESSER`` in
the source code repository.
* New XEP implementations:
* :mod:`aioxmpp.forms` (:xep:`4`): An implementation of the Data Forms XEP.
Take a look and see where it gets you.
* New features in the :mod:`aioxmpp.xso` submodule:
* The new :class:`aioxmpp.xso.ChildFlag` descriptor is a simplification of the
:class:`aioxmpp.xso.ChildTag`. It can be used where the presence or absence of
a child element *only* signals a boolean flag.
* The new :class:`aioxmpp.xso.EnumType` type allows using a :mod:`enum`
enumeration as XSO descriptor type.
* Often-used names have now been moved to the :mod:`aioxmpp` namespace:
* The stanza classes :class:`aioxmpp.IQ`, :class:`aioxmpp.Message`,
:class:`aioxmpp.Presence`
* The type enumerations (see below) :class:`aioxmpp.IQType`,
:class:`aioxmpp.MessageType`, :class:`aioxmpp.PresenceType`
* Commonly used structures: :class:`aioxmpp.JID`,
:class:`aioxmpp.PresenceState`
* Exceptions: :class:`aioxmpp.XMPPCancelError` and its buddies
* **Horribly Breaking Change** in the future: :attr:`aioxmpp.IQ.type_`,
:attr:`aioxmpp.Message.type_`, :attr:`aioxmpp.Presence.type_`
and :attr:`aioxmpp.stanza.Error.type_` now use :class:`aioxmpp.xso.EnumType`,
with corresponding enumerations (see docs of the respective attributes).
This will break about every piece of code ever written for aioxmpp, and it is
not trivial to fix automatically. This is why the following fallbacks have
been implemented:
1. The :attr:`type_` attributes still accept their string (or :data:`None` in
the case of :attr:`.Presence.type_`) values when being written. When being
read, the attributes always return the actual enumeration value.
2. The relevant enumeration members compare equal (and hash equally) to their
values. Thus, ``MessageType.CHAT == "chat"`` is still true (and
``MessageType.CHAT != "chat"`` is false).
3. :meth:`~.StanzaStream.register_message_callback`,
:meth:`~.StanzaStream.register_presence_callback`, and
:meth:`~.StanzaStream.register_iq_request_coro`, as well as their
corresponding un-registration methods, all accept the string variants for
their arguments, internally mapping them to the actual enumeration values.
.. note::
As a matter of fact (good news!), with only the fallbacks and no code
fixes, the :mod:`aioxmpp` test suite passes. So it is likely that you will
not notice any breakage in the 0.7 release, giving you quite some time to
react.
These fallbacks will be *removed* with aioxmpp 1.0, making the legacy use
raise :exc:`TypeError` or fail silently. Each of these fallbacks currently
produces a :exc:`DeprecationWarning`.
.. note::
:exc:`DeprecationWarning` warnings are not shown by default in Python 3. To
enable them, either run the interpreter with the ``-Wd`` option, un-filter
them explicitly using ``warnings.simplefilter("always")`` at the top of
your program, or explore other options as documented in :mod:`warnings`.
So, now I said I will be breaking all your code, how do you fix it? There are
two ways to find affected pieces of code: (1) run it with warnings (see
above), which will find all affected pieces of code and (2) use the shell
script provided at `utils/find-v0.7-type-transitions.sh
`_
to find a subset of potentially affected pieces of code automatically. The
shell script uses `The Silver Searcher (ag) `_
(find it in your distributions package repositories, I know it is there on
Fedora, Arch and Debian!) and regular expressions to find common patterns.
Example usage::
# find everything in the current subdirectory
$ $AIOXMPPPATH/utils/find-v0.7-type-transitions.sh
# only search in the foobar/ subdirectory
$ $AIOXMPPPATH/utils/find-v0.7-type-transitions.sh foobar/
# only look at the foobar/baz.py file
$ $AIOXMPPPATH/utils/find-v0.7-type-transitions.sh foobar/baz.py
The script was built while fixing :mod:`aioxmpp` itself after the bug. It has
not found *all* affected pieces of code, but the vast majority. The others can
be found by inspecting :exc:`DeprecationWarning` warnings being emitted.
* The :func:`aioxmpp.security_layer.make` makes creating a security layer much
less cumbersome than before. It provides a simple interface supporting
password authentication, certificate pinning and others.
The interface of this function will be extended in the future when more
authentication or certificate verification mechanisms come around.
* The two methods :meth:`aioxmpp.muc.Service.get_room_config`,
:meth:`aioxmpp.muc.Service.set_room_config` have been implemented, allowing to
manage MUC room configurations.
* Fix bug in :meth:`aioxmpp.xso.ChildValueMultiMap.to_sax` which rendered XSOs
with that descriptor useless.
* Fix documentation on :meth:`aioxmpp.PresenceManagedClient.set_presence`.
* :class:`aioxmpp.callbacks.AdHocSignal` now logs when coroutines registered
with :meth:`aioxmpp.callbacks.AdHocSignal.SPAWN_WITH_LOOP` raise exceptions or
return non-:data:`None` values. See the documentation of
:meth:`~aioxmpp.callbacks.AdHocSignal.SPAWN_WITH_LOOP` for details.
* :func:`aioxmpp.pubsub.xso.as_payload_class` is a decorator (akin to
:meth:`aioxmpp.IQ.as_payload_class`) to declare that your
:class:`~aioxmpp.xso.XSO` shall be allowed as pubsub payload.
* :meth:`~.StanzaStream.register_message_callback` and
:meth:`~.StanzaStream.register_presence_callback` now explicitly raise
:class:`ValueError` when an attempt to overwrite an existing listener is made,
instead of silently replacing the callback.
Version 0.7.2
-------------
* Fix resource leak which would emit::
task: wait_for= cb=[XMLStream._stream_starts_closing()]>
* Improve compatibility of :mod:`aioxmpp.muc` with Prosody 0.9 and below, which
misses sending the ``110`` status code on some presences.
* Handle inbound message stanzas with empty from attribute. Those are legal as
per :rfc:`6120`, but were not handled properly.
Version 0.6
===========
* New dependencies:
* :mod:`multidict` from :mod:`aiohttp`.
* :mod:`aioopenssl`: This is the former :mod:`aioxmpp.ssl_transport` as a
separate package; :mod:`aioxmpp` still ships with a fallback in case that
package is not installed.
* New XEP implementations:
* partial :mod:`aioxmpp.pubsub` (:xep:`60`): Everything which requires forms
is not implemented yet. Publish/Subscribe/Retract and creation/deletion of
nodes is verified to work (against `Prosody `_ at
least).
* :mod:`aioxmpp.shim` (:xep:`131`), used for :mod:`aioxmpp.pubsub`.
* :xep:`368` support was added.
* New features in the :mod:`aioxmpp.xso` subpackage:
* :class:`aioxmpp.xso.NumericRange` validator, which can be used to validate
the range of any orderable type.
* :mod:`aioxmpp.xso.query`, a module which allows for running queries against
XSOs. This is still highly experimental.
* :class:`aioxmpp.xso.ChildValueMultiMap` descriptor, which uses
:mod:`multidict` and is used in :mod:`aioxmpp.shim`.
* :mod:`aioxmpp.network` was rewritten for 0.5.4
The control over the used DNS resolver is now more sophisticated. Most
notably, :mod:`aioxmpp.network` uses a thread-local resolver which is used for
all queries by default.
Normally, :func:`aioxmpp.network.repeated_query` will now re-configure the
resolver from system-wide resolver configuration after the first timeout
occurs.
The resolver can be overridden (disabling the reconfiguration magic) using
:func:`aioxmpp.network.set_resolver`.
* **Breaking change:** :class:`aioxmpp.service.Service` does not accept a
`logger` argument anymore; instead, it now accepts a `base_logger` argument.
Refer to the documentation of the class for details.
The `base_logger` is automatically passed by
:meth:`aioxmpp.node.AbstractClient.summon` on construction of the service and
is the :attr:`aioxmpp.node.AbstractClient.logger` of the client instance.
* **Breaking change:** :class:`aioxmpp.xso.XSO` subclasses (or more
specifically, instances of the :class:`aioxmpp.xso.model.XMLStreamClass`
metaclass) now automatically declare a :attr:`__slots__` attribute.
The mechanics are documented in detail on
:attr:`aioxmpp.xso.model.XMLStreamClass.__slots__`.
* **Breaking change:** The following functions have been removed:
* :func:`aioxmpp.node.connect_to_xmpp_server`
* :func:`aioxmpp.node.connect_secured_xmlstream`
* :func:`aioxmpp.security_layer.negotiate_stream_security`
Use :func:`aioxmpp.node.connect_xmlstream` instead, but check the docs for the
slightly different semantics.
The following functions have been deprecated:
* :class:`aioxmpp.security_layer.STARTTLSProvider`
* :func:`aioxmpp.security_layer.security_layer`
Use :class:`aioxmpp.security_layer.SecurityLayer` instead.
The existing helper function
:func:`aioxmpp.security_layer.tls_with_password_based_authentication` is still
live and has been modified to use the new code.
* *Possibly breaking change:* The arguments to
:meth:`aioxmpp.CertificateVerifier.pre_handshake` are now completely
different. But as this method is not documented, this should not be a problem.
* *Possibly breaking change:* Attributes starting with ``_xso_`` are now also
reserved on subclasses of :class:`aioxmpp.xso.XSO` (together with the
long-standing reservation of attributes starting with ``xso_``).
* :meth:`aioxmpp.stanza.Error.as_application_condition`
* :meth:`aioxmpp.stanza.make_application_error`
* Several bugfixes in :mod:`aioxmpp.muc`:
* :meth:`aioxmpp.muc.Room.on_message` now receives a proper `occupant` argument
if occupant data is available when the message is received.
* MUCs now autorejoin correctly after a disconnect.
* Fix crash when using :class:`aioxmpp.tracking.MessageTracker` (e.g.
indirectly through :meth:`aioxmpp.muc.Room.send_tracked_message`).
Thanks to `@gudvnir `_ over at github for
pointing this out (see `issue#7
`_).
* Several bugfixes related to :class:`aioxmpp.protocol.XMLStream`:
* :mod:`asyncio` errors/warnings about pending tasks being destroyed after
disconnects should be gone now (:class:`aioxmpp.protocol.XMLStream` now
properly cleans up its running coroutines).
* The :class:`aioxmpp.protocol.XMLStream` is now closed or aborted by the
:class:`aioxmpp.stream.StanzaStream` if the stream fails. This prevents
lingering half-open TCP streams.
See :meth:`aioxmpp.stream.StanzaStream.on_failure` for details.
* Some behaviour changes in :class:`aioxmpp.stream.StanzaStream`:
When the stream is stopped without SM enabled, the following new behaviour has
been introduced:
* :attr:`~aioxmpp.stream.StanzaState.ACTIVE` stanza tokens are set to
:attr:`~aioxmpp.stream.StanzaState.DISCONNECTED` state.
* Coroutines which were spawned due to them being registered with
:meth:`~aioxmpp.stream.StanzaStream.register_iq_request_coro` are
:meth:`asyncio.Task.cancel`\ -ed.
The same as above holds if the stream is closed, even if SM is enabled (as
stream closure is clean and will broadcast unavailable presence server-side).
This provides more fail-safe behaviour while still providing enough feedback.
* New method: :meth:`aioxmpp.stream.StanzaStream.send_and_wait_for_sent`.
:meth:`~aioxmpp.stream.StanzaStream.send_iq_and_wait_for_reply` now also uses
this.
* New method :meth:`aioxmpp.PresenceManagedClient.connected` and new class
:class:`aioxmpp.node.UseConnected`.
The former uses the latter to provide an asynchronous context manager which
starts and stops a :class:`aioxmpp.PresenceManagedClient`. Intended for
use in situations where an XMPP client is needed in-line. It saves a lot of
boiler plate by taking care of properly waiting for the connection to be
established etc.
* Fixed incorrect documentation of :meth:`aioxmpp.disco.Service.query_info`.
Previously, the docstring incorrectly claimed that the method would return the
result of :meth:`aioxmpp.disco.xso.InfoQuery.to_dict`, while it would in fact
return the :class:`aioxmpp.disco.xso.InfoQuery` instance.
* Added `strict` arguments to :class:`aioxmpp.JID`. See the class
docmuentation for details.
* Added `strict` argument to :class:`aioxmpp.xso.JID` and made it non-strict by
default. See the documentation for rationale and details.
* Improve robustness against erroneous and malicious stanzas.
All parsing errors on stanzas are now caught and handled by
:meth:`aioxmpp.stream._process_incoming_erroneous_stanza`, which at least logs
the synopsis of the stanza as parsed. It also makes sure that stream
management works correctly, even if some stanzas are not understood.
Additionally, a bug in the :class:`aioxmpp.xml.XMPPXMLProcessor` has been
fixed which prevented errors in text content from being caught.
* No visible side-effects: Replaced deprecated
:meth:`unittest.TestCase.assertRaisesRegexp` with
:meth:`unittest.TestCase.assertRaisesRegex` (`thanks, Maxim
`_).
* Fix generation of IDs when sending stanzas. It has been broken for anything
but IQ stanzas for some time.
* Send SM acknowledgement when closing down stream. This prevents servers from
sending error stanzas for the unacked stanzas ☺.
* New callback mode :meth:`aioxmpp.callbacks.AdHocSignal.SPAWN_WITH_LOOP`.
* :mod:`aioxmpp.connector` added. This module provides classes which connect and
return a :class:`aioxmpp.protocol.XMLStream`. They also handle TLS
negotiation, if any.
* :class:`aioxmpp.node.AbstractClient` now accepts an `override_peer` argument,
which may be a sequence of connection options as returned by
:func:`aioxmpp.node.discover_connectors`. See the class documentation for
details.
Version 0.6.1
-------------
* Fix :exc:`TypeError` crashes when using :mod:`aioxmpp.entitycaps`,
:mod:`aioxmpp.presence` or :mod:`aioxmpp.roster`, arising from the argument
change to service classes.
Version 0.5
===========
* Support for :xep:`0045` multi-user chats is now available in the
:mod:`aioxmpp.muc` subpackage.
* Mostly transparent support for :xep:`0115` (Entity Capabilities) is now
available using the :mod:`aioxmpp.entitycaps` subpackage.
* Support for transparent non-scalar attributes, which get mapped to XSOs. Use
cases are dicts mapping language tags to strings (such as for message
``body`` elements) or sets of values which are represented by discrete XML
elements.
For this, the method :meth:`~aioxmpp.xso.AbstractType.get_formatted_type` was
added to :class:`aioxmpp.xso.AbstractType` and two new descriptors,
:class:`aioxmpp.xso.ChildValueMap` and :class:`aioxmpp.xso.ChildValueList`,
were implemented.
.. autosummary::
~aioxmpp.xso.ChildValueMap
~aioxmpp.xso.ChildValueList
~aioxmpp.xso.ChildTextMap
**Breaking change**: The above descriptors are now used at several places,
breaking the way these attributes need to be accessed:
* :attr:`aioxmpp.Message.subject`,
* :attr:`aioxmpp.Message.body`,
* :attr:`aioxmpp.Presence.status`,
* :attr:`aioxmpp.disco.xso.InfoQuery.features`,
* and possibly others.
* Several stability improvements have been made. A race condition during stream
management resumption was fixed and :class:`aioxmpp.node.AbstractClient`
instances now stop if non-:class:`OSError` exceptions emerge from the
stream (as these usually indicate an implementation or user error).
:class:`aioxmpp.callbacks.AdHocSignal` now provides full exception
isolation.
* Support for capturing the raw XML events used for creating
:class:`aioxmpp.xso.XSO` instances from SAX is now provided through
:class:`aioxmpp.xso.CapturingXSO`. Helper functions to work with these events
are also provided, most notably :func:`aioxmpp.xso.events_to_sax`, which can
be used to re-create the original XML from those events.
The main use case is to be able to write out a transcript of received XML
data, independent of XSO-level understanding for the data received, provided
the parts which are understood are semantically correct (transcripts will be
incomplete if parsing fails due to incorrect contents).
.. autosummary::
~aioxmpp.xso.CapturingXSO
~aioxmpp.xso.capture_events
~aioxmpp.xso.events_to_sax
This feature is already used in :class:`aioxmpp.disco.xso.InfoQuery`, which
now inherits from :class:`~aioxmpp.xso.CapturingXSO` and provides its
transcript (if available) at
:attr:`~aioxmpp.disco.xso.InfoQuery.captured_events`.
* The core SASL implementation has been refactored in its own independent
package, :mod:`aiosasl`. Only the XMPP specific parts reside in
:mod:`aioxmpp.sasl` and :mod:`aioxmpp` now depends on :mod:`aiosasl`.
* :meth:`aioxmpp.stream.StanzaStream.register_message_callback` is more clearly
specified now, a bug in the documentation has been fixed.
* :mod:`aioxmpp.stream_xsos` is now called :mod:`aioxmpp.nonza`, in accordance
with :xep:`0360`.
* :class:`aioxmpp.xso.Date` and :class:`aioxmpp.xso.Time` are now available to
for :xep:`0082` use. In addition, support for the legacy date time format is
now provided in :class:`aioxmpp.xso.DateTime`.
.. autosummary::
~aioxmpp.xso.Date
~aioxmpp.xso.Time
~aioxmpp.xso.DateTime
* The Python 3.5 compatibility of the test suite has been improved. In a
corner-case, :class:`StopIteration` was emitted from ``data_received``, which
caused a test to fail with a :class:`RuntimeError` due to implementation of
:pep:`0479` in Python 3.5. See the `issue at github
`_.
* Helper functions for reading and writing single XSOs (and their children) to
binary file-like objects have been introduced.
.. autosummary::
~aioxmpp.xml.write_single_xso
~aioxmpp.xml.read_xso
~aioxmpp.xml.read_single_xso
* In 0.5.4, :mod:`aioxmpp.network` was re-written. More details will follow in
the 0.6 changelog. The takeaway is that the network stack now automatically
reloads the DNS configuration after the first timeout, to accommodate to
changing resolvers.
Version 0.4
===========
* Documentation change: A simple sphinx extension has been added which
auto-detects coroutines and adds a directive to mark up signals.
The latter has been added to relevant places and the former automatically
improves the documentations quality.
* :class:`aioxmpp.roster.Service` now implements presence subscription
management. To track the presence of peers, :mod:`aioxmpp.presence` has been
added.
* :mod:`aioxmpp.stream` and :mod:`aioxmpp.nonza` are part of the public
API now. :mod:`aioxmpp.nonza` has gained the XSOs for SASL (previously
in :mod:`aioxmpp.sasl`) and StartTLS (previously in
:mod:`aioxmpp.security_layer`).
* :class:`aioxmpp.xso.XSO` subclasses now support copying and deepcopying.
* :mod:`aioxmpp.protocol` has been moved into the internal API part.
* :class:`aioxmpp.Message` specification fixed to have
``"normal"`` as default for :attr:`~aioxmpp.Message.type_` and relax
the unknown child policy.
* *Possibly breaking change*: :attr:`aioxmpp.xso.XSO.DECLARE_NS` is now
automatically generated by the meta class
:class:`aioxmpp.xso.model.XMLStreamClass`. See the documentation for the
detailed rules.
To get the old behaviour for your class, you have to put ``DECLARE_NS = {}``
in its declaration.
* :class:`aioxmpp.stream.StanzaStream` has a positional, optional argument
(`local_jid`) for ejabberd compatibility.
* Several fixes and workarounds, finally providing ejabberd compatibility:
* :class:`aioxmpp.nonza.StartTLS` declares its namespace
prefixless. Otherwise, connections to some versions of ejabberd fail in a
very humorous way: client says "I want to start TLS", server says "You have
to use TLS" and closes the stream with a policy-violation stream error.
* Most XSOs now declare their namespace prefixless, too.
* Support for legacy (`RFC 3921`__) XMPP session negotiation implemented in
:class:`aioxmpp.node.AbstractClient`. See :mod:`aioxmpp.rfc3921`.
__ https://tools.ietf.org/html/rfc3921
* :class:`aioxmpp.stream.StanzaStream` now supports incoming IQs with the
bare JID of the local entity as sender, taking them as coming from the
server.
* Allow pinning of certificates for which no issuer certificate is available,
because it is missing in the server-provided chain and not available in the
local certificate store. This is, with respect to trust, treated equivalent
to a self-signed cert.
* Fix stream management state going out-of-sync when an erroneous stanza
(unknown payload, type or validator errors on the payload) was received. In
addition, IQ replies which cannot be processed raise
:class:`aioxmpp.errors.ErroneousStanza` from
:meth:`aioxmpp.stream.StanzaStream.send_iq_and_wait_for_reply` and when
registering futures for the response using
:meth:`aioxmpp.stream.StanzaStream.register_iq_response_future`. See the
latter for details on the semantics.
* Fixed a bug in :class:`aioxmpp.xml.XMPPXMLGenerator` which would emit
elements in the wrong namespace if the meaning of a XML namespace prefix was
being changed at the same time an element was emitted using that namespace.
* The defaults for unknown child and attribute policies on
:class:`aioxmpp.xso.XSO` are now ``DROP`` and not ``FAIL``. This is for
better compatibility with old implementations and future features.
Version 0.3
===========
* **Breaking change**: The `required` keyword argument on most
:mod:`aioxmpp.xso` descriptors has been removed. The semantics of the
`default` keyword argument have been changed.
Before 0.3, the XML elements represented by descriptors were not required by
default and had to be marked as required e.g. by setting ``required=True`` in
:class:`.xso.Attr` constructor.
Since 0.3, the descriptors are generally required by default. However, the
interface on how to change that is different. Attributes and text have a
`default` keyword argument which may be set to a value (which may also be
:data:`None`). In that case, that value indicates that the attribute or text
is absent: it is used if the attribute or text is missing in the source XML
and if the attribute or text is set to the `default` value, it will not be
emitted in XML.
Children do not support default values other than :data:`None`; thus, they
are simply controlled by a boolean flag `required` which needs to be passed
to the constructor.
* The class attributes :attr:`~aioxmpp.service.Meta.SERVICE_BEFORE` and
:attr:`~aioxmpp.service.Meta.SERVICE_AFTER` have been
renamed to :attr:`~aioxmpp.service.Meta.ORDER_BEFORE` and
:attr:`~aioxmpp.service.Meta.ORDER_AFTER` respectively.
The :class:`aioxmpp.service.Service` class has additional support to handle
the old attributes, but will emit a DeprecationWarning if they are used on a
class declaration.
See :attr:`aioxmpp.service.Meta.SERVICE_AFTER` for more information on the
deprecation cycle of these attributes.
docs/api/index.rst 0000664 0000000 0000000 00000000720 14160146213 0014415 0 ustar 00root root 0000000 0000000 .. _api:
The :mod:`aioxmpp` package
##########################
.. module:: aioxmpp
The API reference provides the reference documentation of the public and
private APIs of :mod:`aioxmpp`. Everything which is not in the
:ref:`internal-api` section is considered public API and subject to the
:ref:`API stability statement `.
.. toctree::
:maxdepth: 2
stability.rst
aioxmpp.rst
public/index.rst
internal/index.rst
changelog.rst
docs/api/internal/ 0000775 0000000 0000000 00000000000 14160146213 0014371 5 ustar 00root root 0000000 0000000 docs/api/internal/cache.rst 0000664 0000000 0000000 00000000036 14160146213 0016165 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.cache
docs/api/internal/e2etest.rst 0000664 0000000 0000000 00000000040 14160146213 0016470 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.e2etest
docs/api/internal/index.rst 0000664 0000000 0000000 00000000547 14160146213 0016240 0 ustar 00root root 0000000 0000000 .. _internal-api:
Internal tools
##############
.. warning::
This API is internal, for all practical purposes. Some of this may move to
public API at some point. Until then, no external code should rely on
anything in this section.
.. toctree::
:maxdepth: 2
cache
e2etest
network
protocol
statemachine
tasks
utils
xml
docs/api/internal/network.rst 0000664 0000000 0000000 00000000040 14160146213 0016606 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.network
docs/api/internal/protocol.rst 0000664 0000000 0000000 00000000041 14160146213 0016757 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.protocol
docs/api/internal/statemachine.rst 0000664 0000000 0000000 00000000045 14160146213 0017567 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.statemachine
docs/api/internal/tasks.rst 0000664 0000000 0000000 00000000036 14160146213 0016247 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.tasks
docs/api/internal/utils.rst 0000664 0000000 0000000 00000000036 14160146213 0016262 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.utils
docs/api/internal/xml.rst 0000664 0000000 0000000 00000000034 14160146213 0015720 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.xml
docs/api/public/ 0000775 0000000 0000000 00000000000 14160146213 0014033 5 ustar 00root root 0000000 0000000 docs/api/public/adhoc.rst 0000664 0000000 0000000 00000000036 14160146213 0015642 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.adhoc
docs/api/public/avatar.rst 0000664 0000000 0000000 00000000037 14160146213 0016043 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.avatar
docs/api/public/blocking.rst 0000664 0000000 0000000 00000000041 14160146213 0016350 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.blocking
docs/api/public/bookmarks.rst 0000664 0000000 0000000 00000000042 14160146213 0016551 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.bookmarks
docs/api/public/callbacks.rst 0000664 0000000 0000000 00000000042 14160146213 0016500 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.callbacks
docs/api/public/carbons.rst 0000664 0000000 0000000 00000000040 14160146213 0016206 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.carbons
docs/api/public/chatstates.rst 0000664 0000000 0000000 00000000043 14160146213 0016725 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.chatstates
docs/api/public/connector.rst 0000664 0000000 0000000 00000000042 14160146213 0016553 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.connector
docs/api/public/disco.rst 0000664 0000000 0000000 00000000036 14160146213 0015665 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.disco
docs/api/public/dispatcher.rst 0000664 0000000 0000000 00000000043 14160146213 0016710 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.dispatcher
docs/api/public/entitycaps.rst 0000664 0000000 0000000 00000000043 14160146213 0016745 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.entitycaps
docs/api/public/errors.rst 0000664 0000000 0000000 00000000037 14160146213 0016101 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.errors
docs/api/public/forms.rst 0000664 0000000 0000000 00000000036 14160146213 0015712 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.forms
docs/api/public/hashes.rst 0000664 0000000 0000000 00000000037 14160146213 0016040 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.hashes
docs/api/public/httpupload.rst 0000664 0000000 0000000 00000000043 14160146213 0016746 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.httpupload
docs/api/public/i18n.rst 0000664 0000000 0000000 00000000035 14160146213 0015342 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.i18n
docs/api/public/ibb.rst 0000664 0000000 0000000 00000000034 14160146213 0015316 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.ibb
docs/api/public/ibr.rst 0000664 0000000 0000000 00000000034 14160146213 0015336 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.ibr
docs/api/public/im.rst 0000664 0000000 0000000 00000000033 14160146213 0015166 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.im
docs/api/public/index.rst 0000664 0000000 0000000 00000003160 14160146213 0015674 0 ustar 00root root 0000000 0000000 Main classes
############
This section of the API covers the classes which are directly instantiated or
used to communicate with an XMPP server.
.. toctree::
:maxdepth: 2
node
stream
stanza
security_layer
.. _api-xep-modules:
Protocol part and XEP implementations
#####################################
This section contains services (cf. :mod:`aioxmpp.service`) which can be
summoned (cf. :meth:`aioxmpp.Client.summon`) into a client, to extend its
functionality or provide backwards compatibility.
.. toctree::
:maxdepth: 2
adhoc
avatar
blocking
bookmarks
carbons
chatstates
disco
entitycaps
forms
hashes
httpupload
ibb
im
ibr
mdr
muc
ping
presence
pep
private_xml
pubsub
roster
rfc6120
rfc3921
rsm
shim
vcard
version
Less common and helper classes
##############################
The modules in this section implement some of the tools which are used to
provide the functionality of the main classes (such as
:mod:`aioxmpp.callbacks`). In addition, classes and modules which are rarely
used directly by basic clients (such as the :mod:`aioxmpp.sasl` module) are
sorted into this section.
.. toctree::
:maxdepth: 2
structs
tracking
nonza
sasl
errors
i18n
callbacks
connector
dispatcher
misc
APIs mainly relevant for extension developers
#############################################
These APIs are used by many of the other modules, but detailed knowledge is
usually required (for users of :mod:`aioxmpp`) only if extensions are to be
developed.
.. toctree::
:maxdepth: 2
service
xso
docs/api/public/mdr.rst 0000664 0000000 0000000 00000000034 14160146213 0015344 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.mdr
docs/api/public/misc.rst 0000664 0000000 0000000 00000000035 14160146213 0015516 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.misc
docs/api/public/muc.rst 0000664 0000000 0000000 00000000034 14160146213 0015346 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.muc
docs/api/public/node.rst 0000664 0000000 0000000 00000000035 14160146213 0015510 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.node
docs/api/public/nonza.rst 0000664 0000000 0000000 00000000036 14160146213 0015711 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.nonza
docs/api/public/pep.rst 0000664 0000000 0000000 00000000034 14160146213 0015346 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.pep
docs/api/public/ping.rst 0000664 0000000 0000000 00000000035 14160146213 0015520 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.ping
docs/api/public/presence.rst 0000664 0000000 0000000 00000000041 14160146213 0016364 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.presence
docs/api/public/private_xml.rst 0000664 0000000 0000000 00000000044 14160146213 0017115 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.private_xml
docs/api/public/pubsub.rst 0000664 0000000 0000000 00000000037 14160146213 0016065 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.pubsub
docs/api/public/rfc3921.rst 0000664 0000000 0000000 00000000040 14160146213 0015650 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.rfc3921
docs/api/public/rfc6120.rst 0000664 0000000 0000000 00000000040 14160146213 0015642 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.rfc6120
docs/api/public/roster.rst 0000664 0000000 0000000 00000000037 14160146213 0016103 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.roster
docs/api/public/rsm.rst 0000664 0000000 0000000 00000000034 14160146213 0015363 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.rsm
docs/api/public/sasl.rst 0000664 0000000 0000000 00000000035 14160146213 0015525 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.sasl
docs/api/public/security_layer.rst 0000664 0000000 0000000 00000000047 14160146213 0017631 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.security_layer
docs/api/public/service.rst 0000664 0000000 0000000 00000000040 14160146213 0016217 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.service
docs/api/public/shim.rst 0000664 0000000 0000000 00000000035 14160146213 0015523 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.shim
docs/api/public/stanza.rst 0000664 0000000 0000000 00000000037 14160146213 0016065 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.stanza
docs/api/public/stream.rst 0000664 0000000 0000000 00000000037 14160146213 0016060 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.stream
docs/api/public/structs.rst 0000664 0000000 0000000 00000000040 14160146213 0016266 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.structs
docs/api/public/tracking.rst 0000664 0000000 0000000 00000000041 14160146213 0016362 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.tracking
docs/api/public/vcard.rst 0000664 0000000 0000000 00000000036 14160146213 0015663 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.vcard
docs/api/public/version.rst 0000664 0000000 0000000 00000000040 14160146213 0016244 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.version
docs/api/public/xso.rst 0000664 0000000 0000000 00000000034 14160146213 0015373 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.xso
docs/api/stability.rst 0000664 0000000 0000000 00000011514 14160146213 0015315 0 ustar 00root root 0000000 0000000 .. _api-stability:
On API stability and versioning
###############################
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD",
"SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this
section are to be interpreted as described in :rfc:`2119`.
Requiring :pep:`492`
====================
Using a Python interpreter which implements :pep:`492` will not be required at
least until 0.7. Independent of that, version 1.0.0 *will* require :pep:`492`.
There may be features which are not or only inconveniently usable without
:pep:`492` before :pep:`492` support becomes mandatory for :mod:`aioxmpp` (one
example is :meth:`.Client.connected`).
Semantic versioning and version numbers
=======================================
:mod:`aioxmpp` complies with `Semantic Versioning 2.0.0`__. The key points
related to API stability are summarized below for your convenience.
The version of the :mod:`aioxmpp` package can be obtained by inspecting
:data:`aioxmpp.__version__`, which contains the version as a string. Unreleased
versions have the ``-devel`` (up to and including version 0.4) or ``-a0``
(since 0.5) suffix. An additional way to access the version number is
:data:`aioxmpp.version_info`, which provides a tuple which can be compared
against other version tuples to check for a specific range of :mod:`aioxmpp`
versions.
Versions with ``-a0`` suffix are never released; if there will ever be
pre-releases, they start at ``-a1``.
__ http://semver.org/spec/v2.0.0.html
Up to version 1.0.0
===================
Up to version 1.0.0, the API inside the :mod:`aioxmpp` package MAY change
without notice in advance. These changes MAY break code in ways which makes it
impossible to have code which works with both the old and the new version.
Changes to the public, non-plugin API SHOULD NOT break code in a way which
makes it impossible to use with old and new versions.
The :ref:`changelog` may not be complete, but SHOULD contain all changes which
break existing code.
The below text, which describes the behaviour for versions from 1.0 onwards,
MAY change without notice in advance.
From version 1.0.0 onwards
==========================
Branching
---------
Two active development branches are used, one for the next minor and one for
the next major release. The branch for the next minor release is called
``devel-X.Y``, where ``X`` and ``Y`` are the major and minor version number of
the upcoming release, respectively. The branch for the next major release is
called ``devel``.
When a minor release is due, two new branches are created (``devel-X.Yn``,
where ``Yn = Y+1``) and ``release-X.Y.0``. In the ``devel-X.Yn`` branch, a new
commit is made to update the version number to ``(X, Yn, 0, 'a0')``. In the
``release-X.Y.0`` branch, the final preparations for the release (such as
updating the readme) are done. ``devel-X.Y`` is kept around to prepare patch
releases and it receives a commit which increments the patch version number by
one without removing the pre-release marker. When the release has been fully
prepared in ``release-X.Y.0``, the commit is tagged appropriately and packages
for all targets are prepared.
When a major release is due, a new branch is created (``devel-Xn.0``,
where ``Xn = X+1``). The remaining procedure is identical to preparing a minor
release for ``X.0``, including the creation of the two (additional) branches.
When a patch release is needed, the patch must be prepared in a feature branch,
branched off the *oldest* release to which the patch is supposed to be applied.
The feature branch can then be merged in all the ``devel-X.Y`` branches where
the patch is needed. Each of these branches is then merged in the corresponding
``release-X.Y`` branch with a non-fast-forward merge. The version number in the
release branches is bumped and the usual release procedure takes place.
In general, the tree of a commit in a devel branch MUST always have a
prerelease marker. The trees in the heads of the ``release-X.Y`` branches may
not have prerelease markers, if they are also tagged as releases.
An appealing property of this scheme is that merging from lower versions to
upper versions is possible (or the other way round, but not in both directions;
the other way round makes not as much sense though). At the first merge from a
lower to a higher version, a merge conflict with respect to the version number
will appear. Future merges will not have this conflict again, which is also why
merging downwards is not allowed (it would override the lower version number
with the higher version number).
The release with the highest version number is always merged into ``master``.
The version number in ``master`` MUST increase monotonically; patch releases are
thus not merged into ``master``, unless they are made against the most recent
release in ``master``.
Versioning and stability
------------------------
Still to be done.
docs/client_design.rst 0000664 0000000 0000000 00000003476 14160146213 0015357 0 ustar 00root root 0000000 0000000 Client design
#############
The client is designed to be resilent against connection failures. The key part
for this is to implement :xep:`198` (Stream Management), which supports resuming
a previous session without losing messages.
Queues
======
The client features multiple queues:
Active queue
------------
The active queue is the queue where stanzas are stored which are about to be
submitted to the XMPP server. This queue should be empty most of the time, but
messages may aggregate while connection issues are taking place. The active
queue cannot be introspected from outside the client.
Hold queue
----------
Stanzas cannot be placed into the hold queue directly; however, if resumption of
a stream fails (e.g. because the server doesn’t support stream management), the
stanzas from the active queue are moved into the hold queue, for manual review
by the user (it might make sense to not re-send some messages, e.g. if they’re
10 hours old).
Events
======
The client inherently has some events, directly related to the state of the
underlying stream:
.. function:: connecting(nattempt)
A connection attempt is currently being made. It is not known yet whether it
will succeed. It is the *nattempt*\ th attempt to establish the connection since
the last successful connection.
.. function:: connection_failed()
The connection failed during establishment of the connection.
.. function:: connection_made()
The connection was successfully established.
.. function:: connection_lost()
The connection has been lost. Automatic reconnect will take place (unless
*max_reconnect_attempts* is 0; in that case, the ``closed`` event is fired
next).
.. function:: closed()
The maximum amount of reconnects has been surpassed, or the user has
explicitly closed the client by calling :meth:`Client.close`.
docs/conf.py 0000664 0000000 0000000 00000023041 14160146213 0013303 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
########################################################################
# File name: conf.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
# 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
# .
#
########################################################################
# -*- coding: utf-8 -*-
#
# aioxmpp documentation build configuration file, created by
# sphinx-quickstart on Mon Dec 1 08:14:58 2014.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import os
import sys
import runpy
import alabaster
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath('sphinx-data/extensions/'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode',
'sphinx.ext.napoleon',
'sphinx.ext.autosummary',
'aioxmppspecific']
napoleon_numpy_docstring = False
# Add any paths that contain templates here, relative to this directory.
templates_path = ['sphinx-data/templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'aioxmpp'
copyright = '2014 – 2018, Jonas Schäfer'
version_mod = runpy.run_path("../aioxmpp/_version.py")
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = ".".join(map(str, version_mod["version_info"][:2]))
# The full version, including alpha/beta/rc tags.
release = version_mod["__version__"]
del version_mod
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['sphinx-data/build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme_path = [alabaster.get_path()]
html_theme = 'alabaster'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
html_theme_options = {
"github_button": "true",
"github_repo": "aioxmpp",
"github_user": "horazont",
"font_size": "12pt",
}
html_sidebars = {
'**': [
'about.html',
'localtoc.html',
'navigation.html',
'relations.html',
'searchbox.html',
'donate.html',
]
}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# " v documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['sphinx-data/static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'aioxmppdoc'
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'aioxmpp.tex', 'aioxmpp Documentation',
'Jonas Schäfer', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'aioxmpp', 'aioxmpp Documentation',
['Jonas Schäfer'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'aioxmpp', 'aioxmpp Documentation',
'Jonas Schäfer', 'aioxmpp', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {
'https://docs.python.org/3/': None,
'https://pyopenssl.readthedocs.org/en/latest/': None,
'http://babel.pocoo.org/en/latest/': None,
'http://docs.zombofant.net/aiosasl/devel/': None,
'http://docs.zombofant.net/aioopenssl/devel/': None,
'https://distro.readthedocs.org/en/latest/': None,
}
docs/dev-guide/ 0000775 0000000 0000000 00000000000 14160146213 0013655 5 ustar 00root root 0000000 0000000 docs/dev-guide/index.rst 0000664 0000000 0000000 00000013247 14160146213 0015525 0 ustar 00root root 0000000 0000000 Developer Guide
###############
This (very incomplete) document aims at providing some guidance for current and
future developers working on :mod:`aioxmpp`.
Testing
=======
:mod:`aioxmpp` is developed in test-driven development style. You can read up on
the internet what this means in detail, but it boils down to "write code only to
fix tests and write tests to justify writing code".
This implies that the :mod:`aioxmpp` test suite is pretty extensive, and using
the default python unittest runner is not very useful. The recommended test
runner to use is `Nose `_. Nose can be
invoked directly (from within the aioxmpp source repository) on the test suite:
.. code-block:: console
$ nosetests3 tests
.. _dg-end-to-end-tests:
End-to-end tests (or integration tests)
---------------------------------------
.. note::
The module :mod:`aioxmpp.e2etest` also contains quite a bit of information on
the framework and configuration. This probably needs a bit of clean up to
consolidate and deduplicate information.
The normal unittest suite is quite nice, but it consists mostly of unit tests,
which have an important flaw: the *interaction* between units is not well
tested. There are a few exceptions, such as ``tests/test_highlevel.py`` which
tests very few operations through the whole stack (very few, because it is very
cumbersome to write tests in that manner).
To remedy that, :mod:`aioxmpp` features a specialised test runner which allows
for running :mod:`aioxmpp` tests against a real XMPP server. It requires a bit
of configuration (read on), so it won’t work out of the box. It can be invoked
using:
.. code-block:: console
$ python3 -m aioxmpp.e2etest tests
It needs to be configured though. For this, an ini-style configuration file
(using :mod:`configparser`) is read. The default location is
``./.local/e2etest.ini``, but it can be overridden with the ``--e2etest-config``
command line option.
.. note::
``aioxmpp.e2etest`` uses Nose for everything and patches in a plugin and a
few helper functions to provide the advanced testing functionality. This is
also why the vanilla nosetests runner doesn’t break on the test cases.
The following global configuration options exist:
.. code-block:: ini
[global]
timeout=1
provisioner=
``provisioner`` must be set to point to a Python class which inherits from
:class:`aioxmpp.e2etest.provision.Provisioner`. The above value is an example.
Each provisioner has different configuration options. The different provisioners
are explained in detail below.
``timeout`` specifies the default timeout for each individual test in seconds.
The default is 1 second. If you have a slow connection to the server, it may be
reasonable to increase this to a higher value.
To test that you got your configuration correct, use:
.. code-block:: console
$ python3 -m aioxmpp.e2etest tests/test_e2e.py:TestConnect
This should run a single test, which should pass.
Anonymous provisioner
~~~~~~~~~~~~~~~~~~~~~
The anonymous provisioner uses the ``ANONYMOUS`` SASL mechanism to authenticate
with the target XMPP server. This is the most simple provisioner conceivable. An
example config file using that provisioner looks like this:
.. code-block:: ini
[global]
provisioner=aioxmpp.e2etest.provision.AnonymousProvisioner
[aioxmpp.e2etest.provision.AnonymousProvisioner]
domain=localhost
pin_store=pinstore.json
pin_type=0
The ``aioxmpp.e2etest.provision.AnonymousProvisioner`` contains the options
specific to that provisioner.
``domain`` must be a valid JID domainpart and the XMPP host to connect to. This
must be a domain served by the target XMPP server. To connect to a local server
whose hostname and IP address cannot be resolved from the XMPP domain name via
the DNS, you can explicitly set the IP or hostname as well as the port to
connect to with the ``host`` and ``port`` options. If ``domain`` is omitted but
``host`` is set, it is assumed to be the same as ``host``.
``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_verify`` takes precedence over ``pin_store`` and ``pin_type``.
Writing end-to-end tests
------------------------
For now, please see ``tests/test_e2e.py`` as a reference. A few key points:
* Make sure to inherit from :class:`aioxmpp.e2etest.TestCase` instead of
:class:`unittest.TestCase`. This will prevent the tests from running with the
normal nosetests runner and also give you the current provisioner as
``self.provisioner``.
* The :func:`aioxmpp.e2etest.blocking` decorator can be used everywhere to
convert a coroutine function to a normal function. It works by wrapping the
coroutine function in a :meth:`asyncio.BaseEventLoop.run_until_complete` call,
with the usual implications.
* You do not need to clean up the clients obtained from the provisioner; the
provisioner will stop them when the test is over (as if by using a
``tearDown`` method).
* Depending on the provisioner, the number of clients you can use at the same
time may be limited; the anonymous provisioner has no limit.
docs/glossary.rst 0000664 0000000 0000000 00000011227 14160146213 0014404 0 ustar 00root root 0000000 0000000 Glossary
########
This section defines terms used throughout the :mod:`aioxmpp` documentation.
.. glossary::
Character Data Type
An :mod:`aioxmpp.xso` type description class which converts between
strings and other python values. Common examples include
:class:`aioxmpp.xso.Integer` and :class:`aioxmpp.xso.Bool`.
A character data type is a descendant of
:class:`aioxmpp.xso.AbstractCDataType`.
.. seealso::
:ref:`ug-introduction-to-xso-terminology`.
Conversation
A context for communication between two or more :term:`entities `.
It defines a transport medium (such as direct XMPP or a Multi-User-Chat), a
set of members along with their addresses and possibly additional features
such as archive access method.
Conversation Implementation
A module consisting of service that implements
:class:`aioxmpp.im.conversation.AbstractConversationService`
together with implementations of
:class:`aioxmpp.im.conversation.AbstractConversation` and
:class:`aioxmpp.im.conversation.AbstractConversationMember`.
This adds support for one concrete type of conversation to
aioxmpp. Currently, the following conversation implementations
exist: :class:`aioxmpp.im.p2p` and :class:`aioxmpp.muc`.
Conversation Member
Representation of an :term:`entity ` which takes part in a
:term:`conversation `. The actual definition of
"taking part in a conversation" depends on the specific medium
used. Conversation members are represented in aioxmpp as
instances of
:class:`aioxmpp.im.conversation.AbstractConversationMember`.
Conversation Service
A service implementing
:class:`aioxmpp.im.conversation.AbstractConversationService`.
This allows to create and manage :term:`conversations `.
Element Type
An :mod:`aioxmpp.xso` type description class which converts between XML
subtrees and python values.
An element type is a descendant of
:class:`aioxmpp.xso.AbstractElementType`.
.. seealso::
:ref:`ug-introduction-to-xso-terminology`.
Entity
An endpoint in the Jabber network, anything that can be addressed by
a :term:`JID`. (Compare :rfc:`6122` section 2.1)
Jabber ID
JID
Jabber Identifier. The unique address of an :term:`entity
` in the Jabber network. (Compare :rfc:`6122` section 2).
Jabber IDs are represented as :class:`aioxmpp.JID` objects in aioxmpp.
Namespace URI
namespace-uri
The URI which identifies an XML namespace.
In the following examples, the Namespace URI of the shown element is
always ``uri``:
* ````
* ````
* ````
See also `Namespaces in XML 1.0`_.
Local Name
local-name
The local name of an XML element. For both the following examples,
```` and ````, the local
name is ``foo``.
See also `Namespaces in XML 1.0`_.
Tracking Service
A :term:`Service` which provides functionality for updating
:class:`aioxmpp.tracking.MessageTracker` objects.
Service
A subclass of :class:`aioxmpp.service.Service` which supplements the base
:class:`aioxmpp.Client` with additional functionality. Typically, a
service implements a part of one or more :term:`XEPs `.
Service Member
A :term:`Conversation Member` representing the service over which the
conversation is run. For example, some :xep:`45` multi-user chat
service implementations send messages to all occupants as a service user.
Those messages appear in :mod:`aioxmpp.muc` as coming from the service
member.
Relevant entities:
* :attr:`aioxmpp.im.conversation.AbstractConversation.service_member`
* :attr:`aioxmpp.muc.Room.service_member`
* :class:`aioxmpp.im.conversation.AbstractConversationMember`
* :attr:`aioxmpp.muc.ServiceMember`
XEP
XMPP Extension Proposal
An XMPP Extension Proposal (or XEP) is a document which extends the basic
RFCs of the XMPP protocol with additional functionality. Many important
instant messaging features are specified in XEPs. The index of XEPs is
located on `xmpp.org `_.
XSO
XML stream object
A XML stream object (or XSO) is a python representation of an XML subtree.
Its name originates from the fact that it is mostly used with XMPP XML
streams.
The definition and use of XSOs is documented in :mod:`aioxmpp.xso`.
.. _Namespaces in XML 1.0: https://www.w3.org/TR/REC-xml-names/
docs/index.rst 0000664 0000000 0000000 00000017307 14160146213 0013655 0 ustar 00root root 0000000 0000000 .. aioxmpp documentation master file, created by
sphinx-quickstart on Mon Dec 1 08:14:58 2014.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to aioxmpp's documentation!
########################################
Welcome to the documentation of :mod:`aioxmpp`! In case you do not know,
:mod:`aioxmpp` is a pure-python XMPP library written for use with
:mod:`asyncio`.
If you are new to :mod:`aioxmpp`, you should check out the
:ref:`ug-quick-start`, or read on below for an overview of the :ref:`features`
of :mod:`aioxmpp`. If you want to check the API reference to look something up,
you should head to :ref:`api`.
Contents:
.. toctree::
:maxdepth: 2
user-guide/index.rst
api/index.rst
dev-guide/index.rst
glossary.rst
.. _features:
Feature overview
################
.. remember to update the feature list in the README
* Native :xep:`198` (Stream Management) support for robustness against transient
network failures (such as switching between wireless and wired networks).
* Powerful declarative-style definition of XEP-based and custom protocols via
:mod:`aioxmpp.xso` and :mod:`aioxmpp.service`. Most of the time, you will not
get in contact with raw XML or character data, even when implementing a new
protocol.
* Secure by default: TLS is required by default, as well as certificate
validation. Certificate or public key pinning can be used, if needed.
* Support for :rfc:`6121` (Instant Messaging and Presence,
:mod:`aioxmpp.presence`, :mod:`aioxmpp.roster`) roster and presence
management, along with :xep:`45` (Multi-User Chats, :mod:`aioxmpp.muc`) for
your human-to-human needs.
* Support for :xep:`60` (Publish-Subscribe, :mod:`aioxmpp.pubsub`) and :xep:`50`
(Ad-Hoc Commands, :mod:`aioxmpp.adhoc`) for your machine-to-machine needs.
* Several other XEPs, such as :xep:`115` (Entity Capabilities,
:mod:`aioxmpp.entitycaps`, including native support for reading and writing
the `capsdb `_) and :xep:`131` (Stanza
Headers and Internet Metadata, :mod:`aioxmpp.shim`).
* APIs suitable for both one-shot scripts and long-running multi-account
clients.
* Well-tested and modular codebase: :mod:`aioxmpp` is developed in test-driven
style and many modules are automatedly tested against a
`Prosody `_ 0.9, 0.10 and the most recent development
version, as well as `ejabberd `_, two popular XMPP
servers.
.. image:: https://travis-ci.org/horazont/aioxmpp.svg?branch=devel
:target: https://travis-ci.org/horazont/aioxmpp
.. image:: https://coveralls.io/repos/github/horazont/aioxmpp/badge.svg?branch=devel
:target: https://coveralls.io/github/horazont/aioxmpp?branch=devel
Check out the :ref:`ug-quick-start` to get started with :mod:`aioxmpp` now! ☺
Supported protocols
===================
The referenced standards are ordered by their serial number. Not included are
specifications which define procedures which were followed or which are
described in the documentation. Those are also linked at the respective places
throughout the docs.
From IETF RFCs
--------------
* :rfc:`4505` (SASL ANONYMOUS), see :func:`aioxmpp.make_security_layer` and :mod:`aiosasl`
* :rfc:`4616` (SASL PLAIN), see :func:`aioxmpp.make_security_layer` and :mod:`aiosasl`
* :rfc:`5802` (SASL SCRAM), see :func:`aioxmpp.make_security_layer` and :mod:`aiosasl`
* :rfc:`6120` (XMPP Core), including some of the legacy from :rfc:`3920`
* :rfc:`6121` (XMPP Instant Messaging and Presence)
* see :mod:`aioxmpp.presence` for managing inbound presence
* see :mod:`aioxmpp.roster` for managing the roster and presence subscriptions
* :rfc:`6122` (XMPP Address Format)
From XMPP Extension Proposals (XEPs)
------------------------------------
* :xep:`4` (Data Forms), see :mod:`aioxmpp.forms`
* :xep:`27` (Current Jabber OpenPGP Usage), schema-only, see :mod:`aioxmpp.misc`
* :xep:`30` (Service Discovery), see :mod:`aioxmpp.disco`
* :xep:`45` (Multi-User Chat), see :mod:`aioxmpp.muc`
* :xep:`48` (Bookmarks), see :mod:`aioxmpp.bookmarks`
* :xep:`49` (Private XML Storage), see :mod:`aioxmpp.private_xml`
* :xep:`47` (In-Band Bytestreams), see :mod:`aioxmpp.ibb`
* :xep:`50` (Ad-Hoc Commands), see :mod:`aioxmpp.adhoc` (no support for offering
commands to other entities)
* :xep:`59` (Result Set Management), see :mod:`aioxmpp.rsm`
* :xep:`60` (Publish-Subscribe), see :mod:`aioxmpp.pubsub`
* :xep:`66` (Out-of-Band Data), schema-only, see :mod:`aioxmpp.misc`
* :xep:`68` (Field Standardisation for Data Forms), see :mod:`aioxmpp.forms`
* :xep:`77` (In-Band Registration), see :mod:`aioxmpp.ibr`
* :xep:`82` (XMPP Date and Time Profiles), via :class:`aioxmpp.xso.DateTime` and others
* :xep:`84` (User Avatar), see :mod:`aioxmpp.avatar`
* :xep:`92` (Software Version), see :mod:`aioxmpp.version`
* :xep:`106` (JID Escaping), see :func:`aioxmpp.jid_unescape`, :func:`aioxmpp.jid_escape`
* :xep:`115` (Entity Capabilities), see :mod:`aioxmpp.entitycaps`, including
read/write support for the capsdb
* :xep:`163` (Personal Eventing Protocol), see :mod:`aioxmpp.pep`
* :xep:`184` (Message Delivery Receipts), see :mod:`aioxmpp.mdr`
* :xep:`191` (Blocking Command), see :mod:`aioxmpp.blocking`
* :xep:`198` (Stream Management), always enabled if supported by the server
* :xep:`199` (XMPP Ping), used for aliveness-checks if Stream Management is not
available and :mod:`aioxmpp.ping`
* :xep:`203` (Delayed Delivery), see :mod:`aioxmpp.misc`
* :xep:`249` (Direct MUC Invitations), see :mod:`aioxmpp.muc`
* :xep:`297` (Stanza Forwarding), see :mod:`aioxmpp.misc`
* :xep:`280` (Message Carbons), see :mod:`aioxmpp.carbons`
* :xep:`300` (Use of Cryptographic Hash Functions in XMPP),
see :mod:`aioxmpp.hashes`
* :xep:`308` (Last Message Correction), schema-only, see :mod:`aioxmpp.misc`
* :xep:`333` (Chat Markers), schema-only, see :mod:`aioxmpp.misc`
* :xep:`335` (JSON Containers), see :mod:`aioxmpp.misc`
* :xep:`359` (Unique and Stable Stanza IDs), see :mod:`aioxmpp.misc`
* :xep:`363` (HTTP Upload), see :mod:`aioxmpp.httpupload`
* :xep:`368` (SRV records for XMPP over TLS)
* :xep:`379` (Pre-Authenticared Roster Subscription), schema-only, see
:mod:`aioxmpp.misc`
* :xep:`390` (Entity Capabilities 2.0), see :mod:`aioxmpp.entitycaps`
Dependencies
############
.. remember to update the dependency list in the README
* Python ≥ 3.4 (or Python = 3.3 with tulip and enum34)
* DNSPython
* lxml
* `sortedcollections`__
__ https://pypi.python.org/pypi/sortedcollections
* `tzlocal`__ (for i18n support)
__ https://pypi.python.org/pypi/tzlocal
* `pyOpenSSL`__
__ https://pypi.python.org/pypi/pyOpenSSL
* `pyasn1`_ and `pyasn1_modules`__
.. _pyasn1: https://pypi.python.org/pypi/pyasn1
__ https://pypi.python.org/pypi/pyasn1-modules
* `aiosasl`__ (≥ 0.3 for ``ANONYMOUS`` support)
__ https://pypi.python.org/pypi/aiosasl
* `multidict`__
__ https://pypi.python.org/pypi/multidict
* `aioopenssl`__
__ https://github.com/horazont/aioopenssl
Contributing
############
The contribution guidelines are outlined in the README in the source code
repository. The repository is `hosted at GitHub
`_.
Security Issues
###############
If you believe that a bug you found in aioxmpp has security implications,
you are welcome to notify me privately. To do so, send a mail to `Jonas Schäfer
`_, encrypted using the GPG public key::
0xE5EDE5AC679E300F
Fingerprint AA5A 78FF 508D 8CF4 F355 F682 E5ED E5AC 679E 300F
If you prefer to disclose security issues immediately, you can do so at any of
the places listed in the contribution guidelines (see above).
Indices and tables
##################
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
docs/licenses/ 0000775 0000000 0000000 00000000000 14160146213 0013611 5 ustar 00root root 0000000 0000000 docs/licenses/apache20.txt 0000664 0000000 0000000 00000025142 14160146213 0015741 0 ustar 00root root 0000000 0000000 Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
docs/licenses/dnspython.txt 0000664 0000000 0000000 00000001355 14160146213 0016404 0 ustar 00root root 0000000 0000000 Copyright (C) 2001-2003 Nominum, Inc.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose with or without fee is hereby granted,
provided that the above copyright notice and this permission notice
appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
docs/licenses/libxml2.txt 0000664 0000000 0000000 00000002057 14160146213 0015727 0 ustar 00root root 0000000 0000000 Copyright (c)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
docs/licenses/lxml.txt 0000664 0000000 0000000 00000002720 14160146213 0015327 0 ustar 00root root 0000000 0000000 Copyright (c) 2004 Infrae. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
3. Neither the name of Infrae nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INFRAE OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
docs/licenses/orderedset.txt 0000664 0000000 0000000 00000011575 14160146213 0016523 0 ustar 00root root 0000000 0000000 Copyright (c) 2014, Simon Percivall
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name of Ordered Set nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
Copyright (c) 2009 Raymond Hettinger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014 Python Software Foundation; All Rights Reserved" are retained
in Python alone or in any derivative version prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
docs/licenses/pyasn1.txt 0000664 0000000 0000000 00000002462 14160146213 0015571 0 ustar 00root root 0000000 0000000 Copyright (c) 2005-2015, Ilya Etingof
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
docs/message-dispatching.rst 0000664 0000000 0000000 00000004223 14160146213 0016456 0 ustar 00root root 0000000 0000000 Message Dispatching Rewrite
###########################
Issues with the current system:
* Precedence of wildcarding is not clear
* No distinction between "wildcard for a bare JID + all of its resources" and "only the bare JID" (it’s always the wildcard)
* Precedence is important as stanzas are delivered to only exactly one handler.
Proposed solution
=================
* Break the message dispatching out of the StanzaStream for easier re-writing.
* Handle it in a separate class.
* Allow applications to configure which message dispatcher is used.
This allows for:
* Creation of a mesasge dispatcher specialised for Instant Messaging. It could
allow for out-of-band flags for messages, e.g. "Sent—Carbon",
"Received-Carbon", …. The interface could be internal so that we can easily
add features.
* Implement the previous behaviour in a separate class. It would provide a
stable interface for applications not focused on the concept of a
conversation.
Issues
======
* While this gives us nice features, we may want to be able to support both
styles at the same time. This may lead to duplicate messages throughout
different services, which is annoying and potentially bad.
Workaround: Make these dispatchers work filter-style: if they have handled the
message, the message is dropped from dispatching.
-> needs priorities for dispatchers. User-controlled or dispatcher-controlled?
Interface for Message Dispatchers
=================================
.. class:: AbstractMessageDispatcher
.. method:: handle_message(stanza)
Called by the stanza stream when a message is received and has passed
stream-level filters.
Transition path for existing StanzaStream methods
=================================================
1. Allow multiple Message Dispatchers, have a default one which provides that
interface and make the methods simply redirect there.
2. Allow only a single Message Dispatcher and create a legacy one by default.
Use the methods to redirect there. Fail loudly when the message dispatcher
has been changed (or when it is being changed and there are still callbacks
registered).
3. Delete them right away.
docs/new-xml-concept.py 0000664 0000000 0000000 00000007633 14160146213 0015407 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: new-xml-concept.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
# 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
# .
#
########################################################################
"""
New XML concept
===============
The idea is to have stanza classes describe what their contents shall be and how
these are serialized/deserialized to/from XML.
.. class:: StanzaModel.Attr(name=,
type_=StanzaModel.String,
required=False,
restrict=None,
default=None)
*required* must be either a :data:`bool` or a callable taking one
argument. If it is a callable, during validation, it is passed the instance
which is currently being validated. The instance has been fully populated at
that time, but not yet validated. The callable shall return a boolean value
indicating whether the attribute is required or not.
*type_* must be a :class:`StanzaModel.Type`. It is used to parse the string
contents of the attribute and convert it to a python type. To impose
additional restrictions on the type, use the *restrict* argument.
*restrict* must be a :class:`StanzaModel.Restriction` or :data:`None`, which
is used to *validate* the value extracted using the *type_* object. Passing
:data:`None` indicates that all values which are accepted by the *type_* are
valid.
*default* is used if the attribute is absent and *required* evaluates to
:data:`False`. *default* is not checked against either the *type_* or the
*restrict* objects.
.. class:: StanzaModel.Child(match="*",
n=1,
required=False)
*required* works as in :class:`StanzaModel.Attr`.
*n* must be either a positive integer (indicating the exact amount of
elements which may occur) or a tuple of two positive integers. If it is a
tuple, the first value may also be ``0`` and the second value may be
:data:`None`, in which case no upper bound is specified.
*match* must be either a string expression which can be used with
:meth:`lxml.etree._Element.iterchildren` or a :class:`StanzaObject`
class. In the latter case, the :attr:`StanzaObject.TAG` attribute is used as
a matcher. It restricts the set of elements to be considered for this
attribute.
If *n* is a tuple or not equal to ``1``, the elements are stored in a
list. Otherwise, the element must be accessible directly.
.. class:: StanzaModel.Text(type_=StanzaModel.String,
required=False,
restrict=None,
default=None)
All arguments work like those applying to :class:`StanzaModel.Attr`.
.. note::
Mixing :class:`StanzaModel.Text` and :class:`StanzaModel.Child` descriptors
on the same :class:`StanzaObject` won’t end well. The semantics of
:class:`StanzaModel.Text` is undefined in that case.
"""
class IQ(metaclass=StanzaObject):
TAG = "{jabber:client}iq"
type_ = xml_attr(
name="type",
type_=StanzaModel.enum("set", "get", "error", "result"),
required=True
)
data = xml_child(
match="*",
n=1,
required=lambda instance: instance.type_ in {"set", "get"}
)
docs/release-procedure.rst 0000664 0000000 0000000 00000000576 14160146213 0016154 0 ustar 00root root 0000000 0000000 * Branch off into the release branch
* Ensure version number in README.rst link to documentation is correct
* Ensure version number in docs/README.rst link to documentation is correct
* Ensure version number in aioxmpp/version.py is correct
* Ensure that docs are being built
* Create a tag for the commit setting the version numbers and sign it
* Merge branch to master
* Publish!
docs/sphinx-data/ 0000775 0000000 0000000 00000000000 14160146213 0014224 5 ustar 00root root 0000000 0000000 docs/sphinx-data/.gitignore 0000664 0000000 0000000 00000000015 14160146213 0016210 0 ustar 00root root 0000000 0000000 static
build
docs/sphinx-data/extensions/ 0000775 0000000 0000000 00000000000 14160146213 0016423 5 ustar 00root root 0000000 0000000 docs/sphinx-data/extensions/aioxmppspecific.py 0000664 0000000 0000000 00000007711 14160146213 0022166 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: aioxmppspecific.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
# 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
# .
#
########################################################################
import asyncio
from sphinx import addnodes
from sphinx.domains.python import PyModule, PyMethod
from docutils import nodes, utils
from docutils.parsers.rst import roles
from sphinx.locale import _
from sphinx.environment import default_settings
from sphinx.ext.autodoc import (
MethodDocumenter,
FunctionDocumenter,
ModuleLevelDocumenter
)
from sphinx.util.nodes import (
split_explicit_title
)
class SignalAwareMethodDocumenter(MethodDocumenter):
objtype = 'signal'
priority = 4
def import_object(self):
ret = super().import_object()
if not ret:
return ret
self.directivetype = "signal"
return ret
class PySignal(PyMethod):
def handle_signature(self, sig, signode):
ret = super().handle_signature(sig, signode)
signode.insert(0, addnodes.desc_annotation('signal ', 'signal '))
return ret
def run(self):
self.name = 'py:method'
return super().run()
class PySyncSignal(PyMethod):
def handle_signature(self, sig, signode):
ret = super().handle_signature(sig, signode)
signode.insert(0, addnodes.desc_annotation('async signal ', 'async signal '))
return ret
def run(self):
self.name = 'py:method'
return super().run()
def xep_role(typ, rawtext, text, lineno, inliner,
options={}, content=[]):
"""Role for PEP/RFC references that generate an index entry."""
env = inliner.document.settings.env
if not typ:
typ = env.config.default_role
else:
typ = typ.lower()
has_explicit_title, title, target = split_explicit_title(text)
title = utils.unescape(title)
target = utils.unescape(target)
targetid = 'index-%s' % env.new_serialno('index')
anchor = ''
anchorindex = target.find('#')
if anchorindex > 0:
target, anchor = target[:anchorindex], target[anchorindex:]
try:
xepnum = int(target)
except ValueError:
msg = inliner.reporter.error('invalid XEP number %s' % target,
line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]
target = "{:04d}".format(xepnum)
if not has_explicit_title:
title = "XEP-" + target
indexnode = addnodes.index()
targetnode = nodes.target('', '', ids=[targetid])
inliner.document.note_explicit_target(targetnode)
indexnode['entries'] = [
('single', _('XMPP Extension Protocols (XEPs); XEP %s') % target,
targetid, '', None)]
ref = inliner.document.settings.xep_base_url + 'xep-%04d.html' % xepnum
rn = nodes.reference(title, title, internal=False, refuri=ref+anchor,
classes=[typ])
return [indexnode, targetnode, rn], []
roles.register_local_role("xep", xep_role)
default_settings["xep_base_url"] = "https://xmpp.org/extensions/"
def setup(app):
app.add_directive_to_domain('py', 'signal', PySignal)
app.add_directive_to_domain('py', 'syncsignal', PySyncSignal)
app.add_autodocumenter(SignalAwareMethodDocumenter)
return {'version': '1.0', 'parallel_read_safe': True}
docs/user-guide/ 0000775 0000000 0000000 00000000000 14160146213 0014055 5 ustar 00root root 0000000 0000000 docs/user-guide/an-introduction-to-xso.rst 0000664 0000000 0000000 00000041354 14160146213 0021162 0 ustar 00root root 0000000 0000000 .. _ug-introduction-to-xso:
An Introduction to XSO
######################
This document shall serve as an introduction to the :mod:`aioxmpp.xso`
subpackage. This is intentionally separate from the API documentation and
the glossary, since it should provide a high- and user-level introduction for
those who first get into using it.
About :mod:`aioxmpp.xso`
========================
Let us give you an introduction to the :mod:`aioxmpp.xso` package in form of
answers to a few quick questions:
What is :mod:`aioxmpp.xso`?
---------------------------
It is a mapping layer between XML structured data (elements, text and
attributes) and python objects. It is built with streaming in mind.
SAX-compatible events are interpreted and converted to python objects
on-the-fly. No DOM is needed or used.
If you have ever worked with Object-Relational Mappers for databases, such as
:mod:`sqlalchemy`, XSO will feel familiar.
What is :mod:`aioxmpp.xso` **not**?
-----------------------------------
A replacement for a full-blown XML library. If you need the full XML 1.0+ DOM,
XPath, XQuery and/or possibly XSLT to work with your data, XSO is not the right
thing for you.
Specifically, the following XML 1.0 features are decidedly **not supported** in
:mod:`aioxmpp.xso`:
- Non-namespace-well-formed documents: All documents processed and generated by
XSO are namespace well-formed.
- Processing Insturctions
- Comments
- Document Type Declarations
- Preservation of qualified names / namespace prefixes. They are semantically
irrelevant: only the :term:`Namespace URI` and :term:`Local Name` matter.
- Preservation of ordering between some elements. The following relative orders
are specifically violated:
- Text nodes vs. non-text nodes within the same parent element
- Child elements which are handled by different descriptors. Often, only
elements with the same :term:`Namespace URI` and :term:`Local Name` are
handled by the same descriptor (see also
:ref:`ug-introduction-to-xso-descriptors`).
There may be other edge-case features we do not support.
How much `Magic`_ is inside :mod:`aioxmpp.xso`?
-----------------------------------------------
Hopefully not too much, but there’s still a bit. I’ll let you know that there
is at least one metaclass involved, to handle the processing of descriptors at
class-definition time and enforcing invariants during inheritance. Sorry for
that.
Oh, and the use of generators as suspendable functions to make the parsing code
easier to read.
Other than that, I think, it’s pretty standard Python though.
.. _Magic: http://www.catb.org/jargon/html/M/magic.html
Into the Deep End / Very Quick Start
====================================
Let us jump right in:
.. code-block:: python
>>> data = \
... b"" \
... b"some text" \
... b""
>>> namespace = "urn:uuid:203ef66e-4423-49f2-90c9-3cb160986734"
>>> class Node(aioxmpp.xso.XSO):
... TAG = namespace, "node"
... attr = aioxmpp.xso.Attr("a1")
... data = aioxmpp.xso.ChildText((namespace, "child"))
...
>>> buf = io.BytesIO(data)
>>> n = aioxmpp.xml.read_single_xso(buf, Node)
>>> isinstance(n, Node)
True
>>> n.attr
'foo'
>>> n.data
'some text'
Look, you just parsed your first XSO!
.. note::
The :mod:`aioxmpp.xml` module, which is technically not part of
:mod:`aioxmpp.xso`, was also involved. This is because *driving* the XSO
parser with SAX events from a bytes object requires quite some setup, and
there are shorthands for that in :mod:`aioxmpp.xml`.
Let us walk through this step-by-step.
1. ``data = ...``: We simply set up a blob of data for us to parse. There
should be nothing or at least not much special in there. It is simply an
XML fragment with an element which has a single child element.
2. ``class Node``: This declares the XSO class. Inheriting from
:class:`aioxmpp.xso.XSO` is how you say "I want this to be parseable and
serialisable from/to XML". It is required for the descriptors to work.
1. ``TAG = ...``: This sets the :term:`namespace-uri`/:term:`local-name`
pair which identifies this XSO. The identification is not global; thus,
it is allowed to declare multiple XSO descendant classes with the same
TAG.
2. ``attr = aioxmpp.xso.Attr(...)``: :class:`aioxmpp.xso.Attr` is a
descriptor. It is understood by the :class:`aioxmpp.xso.XSO` class and
collected into bookkeeping attributes at class definition time. When
an element needs to be parsed and it has attributes, the parsing function
looks up the attribute tag in the bookkeeping and delegates processing of
the attribute to the descriptor.
3. ``data = aioxmpp.xso.ChildText(...)``: :class:`aioxmpp.xso.ChildText` is
another descriptor. In contrast to the :class:`~aioxmpp.xso.Attr`
descriptor, this one handles child element events (and not attribute
events). If a child element event matching the tag given as first
argument to this descriptor, the parser delegates parsing of that element
to the descriptor.
3. ``buf = ...``: Create a file-like from which the parser function can read.
4. ``n = aioxmpp.xml.read_single_xso``: Read a single XSO from a file-like
object and save it into ``n``.
5. The following attribute accesses show how data has arrived in the instance
of ``Node``.
Again, if you have used an ORM before, how we declared `Node` should be very
familiar to you.
.. _ug-introduction-to-xso-terminology:
A Bit of XSO Terminology
========================
Now after the plunge into the deep end, let us get a bit of terminology
straight so that it is clear what we're talking about:
Character Data
Text or CDATA nodes in the XML document. Text and CDATA are treated the
same by XSO (after the decoding handled by the XML library).
Element
An element node in an XML tree. An element node may hold child nodes,
such as text nodes, other elements and attributes.
Tag
A tag is a pair consisting of a :term:`namespace-uri` and a
:term:`local-name`. It is a fully-qualified name for an XML element. A
common notation for tags is
`Clark’s Notation `_. For example
``{uri:foo}bar`` for a local name ``bar`` and a namespace URI
``uri:foo``.
In XSO, tags are represented as tuples with two strings, reflecting the
structure of the aforementioned pair.
XSO Type
Describes how to map XML data (character data or element subtrees) to
python types and vice versa. Examples are :class:`aioxmpp.xso.Integer`
and :class:`aioxmpp.xso.EnumElementType`.
XSO types can be categorized in two classes:
1. :term:`Character Data Types `, which map character
data to python data structures (e.g. :class:`aioxmpp.xso.Integer`).
2. :term:`Element Types `, which map XML subtrees to python
data structures and vice versa (e.g.
:class:`aioxmpp.xso.EnumElementType`).
Not to be confused with a descendant of :mod:`aioxmpp.xso`.
Writing XSO classes
===================
To write your own XSO class, you simply need a class which inherits (directly
or indirectly) from :class:`aioxmpp.xso.XSO`. Inheriting from that class allows
the descriptors to work.
.. note::
Despite its intricacy, inheritance involving :class:`aioxmpp.xso.XSO`
descendants is fully supported. There are a few invariants which have to be
maintained, however. Violating those invariants will raise an error at
class definition time. In general, those invariants are common sense, but
if you want to dig into the details, see
:class:`aioxmpp.xso.model.XMLStreamClass`.
.. _ug-introduction-to-xso-descriptors:
XSO descriptors
---------------
The descriptors are the main component a user will come in contact with. They
can be categorized into four categories:
*Attribute Descriptors*
which handle attribute nodes, i.e. attributes on the element which the XSO
describes.
*Text Descriptors*
which handle text nodes, i.e. text content (including CDATA sections)
inside the element which the XSO describes.
*Scalar Child Descriptors*
which handle (possibly different) child elements, but at most one of them.
For example, a scalar descriptor which captures one child element of either
of two different types will at any time hold at most one child element; it
cannot hold one of each type. Two different descriptors, or a non-scalar
descriptor is needed for that.
*Non-scalar Child Descriptors*
which handle multiple child elements. These are then aggregated in
different types of containers depending on the specific descriptor.
An overview of all descriptors, grouped by their category, follows. Please
click through to the full classes at one point, because the one-liner
description shown in this summary (as well as the abbreviated argument list)
cannot describe the full potential.
Attribute Descriptors
^^^^^^^^^^^^^^^^^^^^^
.. autosummary::
~aioxmpp.xso.Attr
~aioxmpp.xso.LangAttr
Text Descriptors
^^^^^^^^^^^^^^^^
.. autosummary::
~aioxmpp.xso.Text
Scalar Child Descriptors
^^^^^^^^^^^^^^^^^^^^^^^^
.. autosummary::
~aioxmpp.xso.Child
~aioxmpp.xso.ChildTag
~aioxmpp.xso.ChildFlag
~aioxmpp.xso.ChildText
~aioxmpp.xso.ChildValue
Non-scalar Child Descriptors
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. autosummary::
~aioxmpp.xso.ChildList
~aioxmpp.xso.ChildMap
~aioxmpp.xso.ChildValueList
~aioxmpp.xso.ChildValueMap
~aioxmpp.xso.ChildValueMultiMap
~aioxmpp.xso.ChildLangMap
~aioxmpp.xso.ChildTextMap
~aioxmpp.xso.Collector
Handling of unexpected attributes and child elements
----------------------------------------------------
The handling of unexpected attributes and child elements on an XSO can be
controlled at class definition time using two special attributes:
* :attr:`aioxmpp.xso.XSO.UNKNOWN_CHILD_POLICY` to control how unknown children
are handled. The possible values are :attr:`.UnknownChildPolicy.DROP` (the
default), which simply ignores such child elements and
:attr:`.UnknownChildPolicy.FAIL` which raises an exception.
* :attr:`aioxmpp.xso.XSO.UNKNOWN_ATTR_POLICY` to control how unknown attributes
are handled. The possible values are :attr:`.UnknownAttrPolicy.DROP` (the
default), which simply ignores such attributes and
:attr:`.UnknownAttrPolicy.FAIL` which raises an exception.
.. note::
Unexpected text is always treated as an error.
Character Data Types
--------------------
XML data (beyond the structure) is strings only. However, most protocols built
on top of XML will have types which are used for attributes and text content
more specific than "string".
For example, you’ll commonly find attributes which are integers or booleans and
character data payloads which are base64-encoded binary. For the common types,
:mod:`aioxmpp.xso` ships with type definitions:
.. autosummary::
aioxmpp.xso.String
aioxmpp.xso.Float
aioxmpp.xso.Integer
aioxmpp.xso.Bool
aioxmpp.xso.Base64Binary
aioxmpp.xso.HexBinary
aioxmpp.xso.LanguageTag
aioxmpp.xso.JSON
Some more XMPP specific types are:
.. autosummary::
aioxmpp.xso.DateTime
aioxmpp.xso.Date
aioxmpp.xso.Time
aioxmpp.xso.JID
aioxmpp.xso.ConnectionLocation
.. note::
"What is XMPP-specific about the date types?" you may very well ask. They
do not implement the full syntax of xml schema date, datetime and time
data type definitions.
They should work for most of those values, but some edge-cases (such as
years outside of the range 0..9999) are not handled. See also :xep:`82`.
The types above can be used anywhere where XSO character data types are needed.
Which in turn is every place where XSO handles XML character data, so that’s
attributes (:class:`~aioxmpp.xso.Attr`) and text nodes (e.g. :class:`~aioxmpp.xso.ChildText` and :class:`~aioxmpp.xso.Text`).
Combining the Above in an Example
---------------------------------
We’ve given you lots of theoretical stuff to chew on. Let us put this in
practice with a more sophisticated example.
Hopefully, with the above explanations and the links into the reference
documentation, you will be able to understand this example. If you are not, I
did a bad job at writing this documentation. In that case, I very much would
like to `hear about it `_ to
improve it in the future!
Take this bit of code:
.. code-block:: python
import aioxmpp.xso
namespace = "urn:uuid:39ba7586-fb65-4ec8-80ce-f3a9f2890490"
class Chapter(aioxmpp.xso.XSO):
TAG = namespace, "chapter"
title = aioxmpp.xso.ChildTextMap((namespace, "title"))
start_page = aioxmpp.xso.Attr(
"start-page",
type_=aioxmpp.xso.Integer()
)
class TableOfContents(aioxmpp.xso.XSO):
TAG = namespace, "toc"
chapters = aioxmpp.xso.ChildList([Chapter])
class Book(aioxmpp.xso.XSO):
TAG = namespace, "book"
id_ = aioxmpp.xso.Attr("id")
author = aioxmpp.xso.ChildText((namespace, "author"))
npages = aioxmpp.xso.ChildText(
(namespace, "pages"),
type_=aioxmpp.xso.Integer(),
)
published = aioxmpp.xso.ChildText(
(namespace, "published"),
type_=aioxmpp.xso.Date(),
)
title = aioxmpp.xso.ChildTextMap((namespace, "title"))
toc = aioxmpp.xso.Child([TableOfContents])
class Library(aioxmpp.xso.XSO):
TAG = namespace, "library"
books = aioxmpp.xso.ChildList([Book])
It declares one of the classic examples of XML teaching: a book collection.
Save the above snippet as ``library_demo.py``. Then we can read an XML file
with a ``Library`` shaped root element using the following snippet:
.. code-block:: python
import sys
import aioxmpp.xml
import library_demo
with open(sys.argv[1], "r") as f:
library = aioxmpp.xml.read_single_xso(f, library_demo.Library)
for book in library.books:
print("book (id = {!r}):".format(book.id_))
print(" author:", book.author)
print(" published:", book.published)
print(" npages:", book.npages)
print(" title:")
for lang, title in book.title.items():
print(" [{!s}] {!r}".format(lang, title))
print(" table of contents:")
for i, chapter in enumerate(book.toc.chapters, 1):
print(" {}. (page {})".format(i, chapter.start_page))
for lang, title in chapter.title.items():
print(" [{!s}] {!r}".format(lang, title))
Save that file as ``library_load.py`` and try it on the following XML file
(``library_test.xml``):
.. code-block:: xml
The Amazing Life of FooDas Faszinierende Leben des FooF. Nord2099-01-0123The Birth of FooDie Geburt des FooThe Death of FooDer Tod des FooThe Relevance of Pink Flamingos to Computer ScienceDie Relevanz von rosa Flamingos für die InformatikO. L. Bilderrahmen2007-01-0142
Try it:
.. code-block:: console
$ python3 library_load.py library_test.xml
book (id = 'foo'):
author: F. Nord
published: 2099-01-01
npages: 23
title:
[en] 'The Amazing Life of Foo'
[de] 'Das Faszinierende Leben des Foo'
table of contents:
1. (page 1)
[en] 'The Birth of Foo'
[de] 'Die Geburt des Foo'
2. (page 3)
[en] 'The Death of Foo'
[de] 'Der Tod des Foo'
book (id = 'pink-flamingos'):
author: O. L. Bilderrahmen
published: 2007-01-01
npages: 42
title:
[en] 'The Relevance of Pink Flamingos to Computer Science'
[de] 'Die Relevanz von rosa Flamingos für die Informatik'
table of contents:
Reference Documentation
=======================
To learn more about XSO and how to use it, check out the reference
documentation in :mod:`aioxmpp.xso`. The remainder of this documentation will
now dive deeper into the details on how XSO works.
docs/user-guide/faq.rst 0000664 0000000 0000000 00000007444 14160146213 0015367 0 ustar 00root root 0000000 0000000 Frequently Asked Questions
##########################
Why yet another XMPP library for Python / asyncio?
==================================================
When we started the work on :mod:`aioxmpp`, there was no asyncio XMPP library
for Python. We considered porting `SleekXMPP `_, but
did not do so for two reasons:
* We didn’t think that a port to asyncio would be feasible without much of a
re-write (we’ve been proven wrong, see below!).
* We think that the declarative and highly typed approach we’re following is
more pythonic and leads to less code duplication.
We learnt a few years later that at approximately the same time we started with
aioxmpp (formerly called ``asyncio_xmpp``), the
`slixmpp `_ folks started to work on that fork
of SleekXMPP for asyncio.
Why does aioxmpp try to handle *everything*?
============================================
First of all, we don’t ;-). More to the point though, we feel that XMPP has a
lot of hard problems (some of which are inherent because IM is actually a
rather hard problem, some of which because of historical cruft), and those
problems deserve a proper solution.
We also think that the understanding of the difficulties of a problem decreases
the farther away from the problem you are. This means that client developers
using some client library would have less understanding about the difficulties
of IM than the developers of the client library.
This implies that the best place to solve XMPP and XMPP-IM specific issues is
close to the source, which is in the client library.
At the same time, we appreciate that there’s no one-size-fits-all. This is why
aioxmpp is extremely modular, and you often have the choice whether you use
a module from aioxmpp or whether you roll your own.
Prominent examples of things which look easy at first but are actually quite
tricky to get right:
* Stream Management (:xep:`198`). Really. In our opinion, Stream Management
needs to be thought about at the very beginning (which is why it is highly
integrated in aioxmpp) and downstream code sometimes needs to be aware of
the difference between a suspended (disconnected) stream and a destroyed
(disconnected and not resumable) stream.
* Private XML Storage (:xep:`49`) (and some of the :xep:`222`/:xep:`223` based
protocols) needs a complex read-modify-write-test loop to cover the case when
multiple clients modify the same storage at the same time. This is
unfortunate and annoying, and hard to get right. Client developers shouldn’t
have to care about this: the operations exposed should be things like
``update_bookmark`` or ``remove_bookmark`` and the library should take care
that it either works verifiably or a proper error is raised.
* Multi-User Chats (:xep:`45`) are neat, but they have a lot of weird corner
cases, some stemming from interference with modern protocols (such as
:xep:`280` (Message Carbons)), some from oversights in the original design
related to potential issues between servers. :mod:`aioxmpp.muc` tries to
address those concerns without the application needing to care about it.
Shouldn’t X be up to the application?
=====================================
First, please read the previous point. If you still think that our way of
handling things in a particular case breaks your use-case, please
`drop us an issue on GitHub `_
or the mailing list (see the README for ways to get in contact). We’ll be happy
to either work out a solution which works for you, or adapt the code so that
the use-case is covered.
(Examples of such things in the past:
`add method to disable XEP-0198 `_,
`Using simple connector without SSL or TLS `_)
docs/user-guide/index.rst 0000664 0000000 0000000 00000000304 14160146213 0015713 0 ustar 00root root 0000000 0000000 User guide
##########
The user guide aims to provide a full step-by-step reference on using the
library.
.. toctree::
installation
quickstart
pitfalls
faq
an-introduction-to-xso
docs/user-guide/installation.rst 0000664 0000000 0000000 00000011515 14160146213 0017313 0 ustar 00root root 0000000 0000000 Installation
############
You have three options for installing :mod:`aioxmpp`:
1. :ref:`ug-installation-packages`: only on ArchLinux and only if you use AUR,
but if you do, this is the preferred way for your platform if you want
to use :mod:`aioxmpp` in your project. It is not recommended if you want to
hack on :mod:`aioxmpp` (use the third way then).
2. :ref:`ug-installation-pypi`: this is recommended if you simply want to use
:mod:`aioxmpp` in a project or need it as an dependency for something. It is
not recommended if you want to hack on :mod:`aioxmpp`.
3. :ref:`ug-installation-source`: this is recommended if you want to hack on
:mod:`aioxmpp` or if you anticipate requiring bugfixes or new features while
you use :mod:`aioxmpp`.
.. note::
You can help adding a third (and then new first way, because that way is the
one I prefer most) way: Become a package maintainer for :mod:`aioxmpp` for
your favourite Linux distribution. `rku `_ was so
kind to create an `ArchLinux package in AUR
`_, but other
distributions are still lacking the awesomeness (``;-)``) of :mod:`aioxmpp`.
*You* can change that.
.. _ug-installation-packages:
Installing using your system’s package manager
==============================================
Currently, aioxmpp is only packaged in AUR of ArchLinux. On ArchLinux, that
is the preferred way to install aioxmpp.
For other environments, you have to resort to the ways outlined below.
.. _ug-installation-pypi:
Installing from PyPI
====================
.. _ug-installation-pypi-deps-packages:
Installing dependencies using your system’s package manager (recommended)
-------------------------------------------------------------------------
For Debian 8 (Jessie):
.. code-block:: bash
apt install --no-install-recommends python3-dnspython python3-openssl \
python3-pyasn1 python3-pyasn1-modules build-essential libxml2-dev \
libxslt1-dev python3-dev libz-dev python3-pip
For Debian 9 (Stretch):
.. code-block:: bash
apt install --no-install-recommends python3-dnspython python3-openssl \
python3-pyasn1 python3-pyasn1-modules python3-multidict \
python3-tzlocal python3-lxml python3-babel python3-pip
.. _ug-installation-pypi-deps-pypi:
Installing dependencies from PyPI
---------------------------------
You will need some build dependencies for the dependencies, since some (such as
lxml and PyOpenSSL) include C code which will be built during installation.
In addition, we recommend installing PyOpenSSL using your system’s package
manager even if you install other dependencies using pip.
For Debian 8 (Jessie) and 9 (Stretch):
.. code-block:: bash
apt install --no-install-recommends build-essential libssl-dev \
libxml2-dev libxslt1-dev python3-dev python3-openssl libz-dev \
python3-pip
You can now proceed to installing aioxmpp via pip, which will install the
dependencies from pip too.
Installing aioxmpp
------------------
Now, simply running
.. code-block:: bash
pip3 install aioxmpp
should install everything necessary to run aioxmpp.
.. note::
On Debian Jessie (Debian 8), the pip from the packages is too old to install
aioxmpp: it does not know the ``~=`` version comparison operator. This is
unfortunate, but ``~=`` provides safety against accidental incompatible
changes in dependencies.
To install on Debian Jessie, you will need to upgrade pip using:
.. code-block:: bash
pip3 install --upgrade setuptools
pip3 install --upgrade pip
(You may add the ``--user`` flag or use a virtualenv if you don’t want to
upgrade pip system-wide.)
.. _ug-installation-source:
Installing in editable mode from source
=======================================
Editable mode allows you to hack on aioxmpp while still being able to import it
from everywhere. You can read more about it in the relevant chapter from the
`Python Packaging User Guide
`_.
To install in editable mode, you first need a clone of the aioxmpp repository.
Then you tell pip to install the local directory in editable mode. If you
prefer to install dependencies using your system’s package manager, be sure
to do so first (see :ref:`ug-installation-pypi-deps-packages`), because
:program:`pip3` will install them for you if they are missing.
.. code-block:: bash
git clone https://github.com/horazont/aioxmpp
cd aioxmpp
git checkout devel # make sure to use the devel branch
pip3 install -e . # install in editable mode
Running the unittests
---------------------
To run the unittests, I personally recommend using the nosetests runner:
.. code-block:: bash
cd path/to/source/of/aioxmpp
nosetests3 tests
If any of the tests fail for you, this is worth a bug report.
docs/user-guide/pitfalls.rst 0000664 0000000 0000000 00000013560 14160146213 0016432 0 ustar 00root root 0000000 0000000 Pitfalls to avoid
#################
These are corner cases which should not happen in the common usages, but if they
do, what happens may be very confusing. Here are some tips.
When my application exits uncleanly, it still appears to be online to other resources
=====================================================================================
Congratulations! You are using a server with support for
:xep:`Stream Management <198>`. As you might know, :mod:`aioxmpp` transparently
and automatically uses Stream Management whenever it is available. This means
that :class:`aioxmpp.Client` instances must *always* be
:meth:`aioxmpp.Client.stop`\ -ed properly, so that the stream can be shut down
to prevent it from lingering on the server side. The preferred way to do this
is to use the :meth:`aioxmpp.Client.connected`
:term:`asynchronous context manager`:
.. code-block:: python
client = aioxmpp.Client()
with client.connected() as stream:
# stream is the aioxmpp.stream.StanzaStream of the client
# do something
When the context manager is left (either with an exception or normally), the
connection is closed cleanly.
If the context manager cannot be used, other means to ensure that
:meth:`aioxmpp.Client.stop` is called and the client is given enough time to
shut the connection down cleanly need to be applied. This can be done in the
following manner:
.. code-block:: python
if client.running:
fut = asyncio.Future()
client.on_stopped.connect(fut, client.on_stopped.AUTO_FUTURE)
client.on_failure.connect(fut, client.on_failure.AUTO_FUTURE)
try:
yield from fut
except:
# we are shutting down, ignore any exceptions from on_failure
pass
Ensure that this snippet is executed before the application exits, even in
the case that an error occurred.
.. note::
You may be asking "But why is the Connection Reset by Peer the server must
be getting after my application crashed not enough?". The whole reason for
Stream Management is to make it possible for the server to ignore such
errors which may very well occur if network connectivity is briefly
interrupted (for example when switching between networks, or your ISP has a
power failure, or you reboot your modem or something like that). Stream
Management allows to resume an uncleanly closed stream up to a certain
timeout (as (possibly dynamically) determined by the server). Making errors
such as Connection Reset by Peer break such a stream would defeat the
purpose of Stream Management.
.. note::
As of version 0.9, you can disable the resumption capaibility of Stream
Management using the :attr:`.Client.resumption_timeout` attribute. However,
that alone is no guarantee that sessions die quickly; it still depends a
lot on the way in which the network connection got interrupted and whether
or not the server is sending data to the (disconnected) client.
There are other timeouts, such as the ones from TCP, at play here which
need to be tweaked properly on the server-side. How to do so is out of
scope for aioxmpp.
(If you happen to find a client-side way, e.g. in another XMPP library, to
achieve the behaviour of letting the session die quickly in case of a
hard disconnect (e.g. a pulled cable), let me know. I’m quite convinced
that this is impossible, so I’d like to be proven wrong.)
I am trying to connect to a bare IP and I get a DNS error
=========================================================
For example, when trying to connect to ``192.168.122.1``, you may see::
Traceback (most recent call last):
File "/home/horazont/aioxmpp/aioxmpp/network.py", line 272, in repeated_query
raise_on_no_answer=False
File "/usr/lib/python3.4/asyncio/futures.py", line 388, in __iter__
yield self # This tells Task to wait for completion.
File "/usr/lib/python3.4/asyncio/tasks.py", line 286, in _wakeup
value = future.result()
File "/usr/lib/python3.4/asyncio/futures.py", line 277, in result
raise self._exception
File "/usr/lib/python3.4/concurrent/futures/thread.py", line 54, in run
result = self.fn(*self.args, **self.kwargs)
File "/home/horazont/.local/lib/python3.4/site-packages/dns/resolver.py", line 1051, in query
raise NXDOMAIN(qnames=qnames_to_try, responses=nxdomain_responses)
dns.resolver.NXDOMAIN: None of DNS query names exist: _xmpp-client._tcp.192.168.122.1., _xmpp-client._tcp.192.168.122.1.
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/home/horazont/aioxmpp/aioxmpp/node.py", line 710, in _on_main_done
task.result()
File "/usr/lib/python3.4/asyncio/futures.py", line 277, in result
raise self._exception
File "/usr/lib/python3.4/asyncio/tasks.py", line 233, in _step
result = coro.throw(exc)
File "/home/horazont/aioxmpp/aioxmpp/node.py", line 868, in _main
yield from self._main_impl()
File "/home/horazont/aioxmpp/aioxmpp/node.py", line 830, in _main_impl
logger=self.logger)
File "/home/horazont/aioxmpp/aioxmpp/node.py", line 337, in connect_xmlstream
logger=logger,
File "/home/horazont/aioxmpp/aioxmpp/node.py", line 142, in discover_connectors
"xmpp-client",
File "/home/horazont/aioxmpp/aioxmpp/network.py", line 318, in lookup_srv
**kwargs)
File "/home/horazont/aioxmpp/aioxmpp/network.py", line 280, in repeated_query
"nameserver error, most likely DNSSEC validation failed",
aioxmpp.network.ValidationError: nameserver error, most likely DNSSEC validation failed
You should be using :attr:`aioxmpp.Client.override_peer` or an equivalent
mechanism. Note that the exception will still occur if the connection attempt to
the override fails. Bare IPs as target hosts are generally not a good idea.
docs/user-guide/quickstart.rst 0000664 0000000 0000000 00000032450 14160146213 0017005 0 ustar 00root root 0000000 0000000 .. _ug-quick-start:
Quick start
###########
This chapter wants to get you started quickly with :mod:`aioxmpp`. It will spare
you with most architectural and design details and simply throw some code
snippets at you which do some work.
.. note::
Even though :mod:`aioxmpp` does technically not require it, we will use
:pep:`492` features throughout this chapter.
It makes the code much more concise at some points; there are (currently)
always ways around having to use :pep:`492` features—please refer to the
examples along with the source code to see alternatives e.g. for connecting.
In this section, we will assume that you are familiar with the basic concepts of
XMPP. If you are not, you may still try to walk through this, but a lot of
things which are obvious when you are used to work with XMPP will not be
explained.
Preparations
============
We assume that you have both a :class:`aioxmpp.JID` and a password as
:class:`str` at hand. One way to obtain would be to ask the user::
jid = aioxmpp.JID.fromstr(input("JID: "))
password = getpass.getpass()
.. note::
:mod:`getpass` is a standard Python module for blockingly asking the user for
a password in a terminal. You can use different means of obtaining a
password. Most importantly, in this tutorial, you could replace `password`
with a coroutine taking two arguments, a :class:`aioxmpp.JID` and an integer;
the integer would increase with every authentication attempt during a
connection attempt (starting at 0). The caller expects that the coroutine
returns a password to try, or :data:`None` to abort the authentication.
In fact, passing a :class:`str` as password below simply makes the code wrap
that :class:`str` in a coroutine which returns the :class:`str` when the
second argument is zero and :data:`None` otherwise.
Connect to an XMPP server, with JID and Password
================================================
To connect to an XMPP server, we use a :class:`aioxmpp.PresenceManagedClient`::
client = aioxmpp.PresenceManagedClient(
jid,
aioxmpp.make_security_layer(password)
)
async with client.connected() as stream:
...
At ``...``, the client is connected and has sent initial presence with an
available state. We will get back to the `stream` object returned by the context
manager later on.
Relevant documentation:
* :func:`aioxmpp.security_layer.make`, :mod:`aioxmpp.security_layer`
* :meth:`aioxmpp.PresenceManagedClient.connected`
Send a message
==============
We assume that you did the part from the previous section, and we’ll now work
inside the ``async with`` block::
msg = aioxmpp.Message(
to=recipient_jid, # recipient_jid must be an aioxmpp.JID
type_=aioxmpp.MessageType.CHAT,
)
# None is for "default language"
msg.body[None] = "Hello World!"
await client.send(msg)
Relevant documentation:
* :class:`aioxmpp.Message`
* :meth:`aioxmpp.Client.send`
.. note::
Want to send an IQ instead? IQs are a bit more complex, due to their rather
formal nature. We suggest that you read through this quickstart step-by-step,
but you may as well jump ahead to :ref:`ug-quickstart-send-iq`.
Change presence
===============
:meth:`aioxmpp.PresenceManagedClient.connected` automatically sets an
available presence. To change presence during runtime, there are two ways::
# the simple way: simply set to Do-Not-Disturb
client.presence = aioxmpp.PresenceState(available=True, show="dnd")
# the advanced way: change presence and set the textual status
client.set_presence(
aioxmpp.PresenceState(available=True, show="dnd"),
"Busy with stuff",
)
Relevant documentation:
* :class:`aioxmpp.PresenceState`
* :meth:`aioxmpp.PresenceManagedClient.set_presence` (It also accepts
dictionaries instead of strings. Want to know why? Read the documentation! ☺), :attr:`aioxmpp.PresenceManagedClient.presence`
React to messages (Echo Bot)
============================
Of course, you can react to messages. For simple use-cases, you can use the
:class:`aioxmpp.dispatcher.SimpleMessageDispatcher` service. You better do this
before connecting, to avoid race conditions. So the following code should run
before the ``async with``. To get all chat messages, you could use::
import aioxmpp.dispatcher
def message_received(msg):
print(msg)
# obtain an instance of the service (we’ll discuss services later)
message_dispatcher = client.summon(
aioxmpp.dispatcher.SimpleMessageDispatcher
)
# register a message callback here
message_dispatcher.register_callback(
aioxmpp.MessageType.CHAT,
None,
message_received,
)
The `message_received` callback will be called for all ``"chat"`` messages from
any sender. As it stands, the callback is not very useful, because the `msg`
argument is the :class:`aioxmpp.Message` object and printing it won’t show the
message contents.
This example can be modified to be an echo bot by implementing the
``message_received`` callback differently::
def message_received(msg):
if not msg.body:
# do not reflect anything without a body
return
reply = msg.make_reply()
reply.body.update(msg.body)
client.enqueue(reply)
.. note::
A slightly more verbose version can also be found in the examples directory,
as ``quickstart_echo_bot.py``.
* :class:`aioxmpp.dispatcher.SimpleMessageDispatcher`,
:meth:`~aioxmpp.dispatcher.SimpleStanzaDispatcher.register_callback`.
Definitely check this out for the semantics of the first two arguments!
* :class:`aioxmpp.Message`
* :meth:`~aioxmpp.Client.enqueue`
* :meth:`aioxmpp.Client.summon`
React to presences
==================
Similar to handling messages, presences can also be handled.
.. note::
There exists a service which handles and manages peer presence
(:class:`aioxmpp.PresenceClient`) and one which manages roster
subscriptions (:class:`aioxmpp.RosterClient`), which make most manual
handling of presence obsolete. Read on on how to use services.
Again, the code should be run before
:meth:`~aioxmpp.PresenceManagedClient.connected`::
import aioxmpp.dispatcher
def available_presence_received(pres):
print(pres)
presence_dispatcher = client.summon(
aioxmpp.dispatcher.SimplePresenceDispatcher,
)
presence_dispatcher.register_callback(
aioxmpp.PresenceType.AVAILABLE,
None,
available_presence_received,
)
Again, the whole :class:`aioxmpp.Presence` stanza is passed to the
callback.
Relevant documentation:
* :class:`aioxmpp.dispatcher.SimplePresenceDispatcher`,
:meth:`~aioxmpp.dispatcher.SimpleStanzaDispatcher.register_callback`.
Definitely check this out for the semantics of the first two arguments.
* :class:`aioxmpp.Presence`
React to IQ requests
====================
Reacting to IQ requests is slightly more complex. The reason is that a client
must always reply to IQ requests. Thus, it is most natural to use coroutines as
IQ request handlers, instead of normal functions::
async def request_handler(request):
print(request)
client.stream.register_iq_request_handler(
aioxmpp.IQType.GET,
aioxmpp.disco.xso.InfoQuery,
request_handler,
)
The coroutine is spawned for each request. The coroutine must return a valid
value for the :attr:`aioxmpp.IQ.payload` attribute, or raise an
exception, ideally one derived from :class:`aioxmpp.errors.XMPPError`. The
exception will be converted to a proper ``"error"`` IQ response.
Relevant documentation:
* :meth:`~aioxmpp.stream.StanzaStream.register_iq_request_handler`
* :class:`aioxmpp.IQ`
* :class:`aioxmpp.errors.XMPPError`
Use services
============
Services have now been mentioned several times. The idea of a
:class:`aioxmpp.service.Service` is to implement a specific XEP or a part of
the XMPP protocol. Services essentially do the same thing as discussed
in the previous sections (sending and receiving messages, IQs and/or presences),
but encapsulated away in a class. For details on that, see
:mod:`aioxmpp.service` and an implementation, such as
:class:`aioxmpp.DiscoClient`.
Here we’ll show how to use services::
client = aioxmpp.PresenceManagedClient(
jid,
aioxmpp.make_security_layer(password)
)
disco = client.summon(aioxmpp.DiscoClient)
async with client.connected() as stream:
info = await disco.query_info(
target_jid,
)
In this case, `info` is a :class:`aioxmpp.disco.xso.InfoQuery` object returned
by the entity identified by `target_jid`.
The idea of services is to abstract away the details of the protocol
implemented, and offer additional features (such as caching). Several services
are offered by :mod:`aioxmpp`; most XEPs supported by :mod:`aioxmpp` are
implemented as services. An overview of the existing services can be found in
the API reference at :ref:`api-aioxmpp-services`.
Relevant docmuentation:
* :meth:`aioxmpp.Client.summon`
* :mod:`aioxmpp.disco`, :class:`aioxmpp.DiscoClient`,
:meth:`~aioxmpp.DiscoClient.query_info`
Use :class:`aioxmpp.PresenceClient` presence implementation
===========================================================
This section is mainly there to show you a service which is mostly used with
callbacks::
client = aioxmpp.PresenceManagedClient(
jid,
aioxmpp.make_security_layer(password)
)
def peer_available(jid):
print("{} came online".format(jid))
def peer_unavailable(jid):
print("{} went offline".format(jid))
presence = client.summon(aioxmpp.PresenceClient)
presence.on_bare_available.connect(peer_available)
presence.on_bare_unavailable.connect(peer_unavailable)
async with client.connected() as stream:
await asyncio.sleep(10)
This simply stays online for ten seconds and prints the bare JIDs from which
available and unavailable presence is received.
Relevant documentation:
* :class:`aioxmpp.PresenceClient`
* :class:`aioxmpp.callbacks.AdHocSignal`
React to a XEP-0092 Software Version IQ request
===============================================
This time, we want to stay online for 30 seconds and serve :xep:`92` software
version requests. The format for those is already defined in
:mod:`aioxmpp.version`, so we can re-use that. Before we go into how to use
that, we will briefly show what such a format definition looks like:
.. code:: python
namespaces.xep0092_version = "jabber:iq:version"
@aioxmpp.IQ.as_payload_class
class Query(xso.XSO):
TAG = namespaces.xep0092_version, "query"
version = xso.ChildText(
(namespaces.xep0092_version, "version"),
default=None,
)
name = xso.ChildText(
(namespaces.xep0092_version, "name"),
default=None,
)
os = xso.ChildText(
(namespaces.xep0092_version, "os"),
default=None,
)
The XML element is defined declarative-style as class. The ``TAG`` attribute
defines the fully qualified name of the XML element to match, in this case,
it is the ``query`` element in the ``jabber:iq:version`` namespace.
The other attributes are XSO properties (see :mod:`aioxmpp.xso`). In this case,
all properties are :class:`aioxmpp.xso.ChildText` properties. Each of those
maps to the text content of a child element, again identified by their
respective fully qualified names. The ``name`` attribute for example maps to the
text of the ``name`` child in the ``jabber:iq:version`` namespace.
You do not need to include this code in your application, because it’s already
there in aioxmpp. You can import it using
``from aioxmpp.version.xso import Query``.
Now to reply to version requests, we register a coroutine to handle IQ requests
(before the ``async with``)::
from aioxmpp.version.xso import Query
async def handler(iq):
print("software version request from {!r}".format(iq.from_))
result = Query()
result.name = "aioxmpp Quick Start Pro"
result.version = "23.42"
result.os = "MFHBμKOS (My Fancy HomeBrew Micro Kernel Operating System)"
return result
client.stream.register_iq_request_handler(
aioxmpp.IQType.GET,
Query,
handler,
)
async with client.connected():
await asyncio.sleep(30)
While the client is online, it will respond to IQ requests of type ``"get"``
which carry a :class:`Query` payload; the payload is identified by its qualified
XML name (that is, the namespace and element name tuple). :mod:`aioxmpp` was
made aware of the :class:`Query` using the
:meth:`aioxmpp.IQ.as_payload_class` descriptor.
It then calls the `handler` coroutine we declared with the
:class:`aioxmpp.IQ` object as its only argument. The coroutine is
expected to return a valid payload (hint: :data:`None` is also a valid payload)
for the ``"result"`` IQ or raise an exception (which would be converted to an
``"error"`` IQ).
Relevant documentation:
* :meth:`aioxmpp.stream.StanzaStream.register_iq_request_handler`
* :meth:`aioxmpp.IQ.as_payload_class`
* :class:`aioxmpp.version.xso.Query`
.. note::
In general, you should check whether aioxmpp implements a feature already.
In this case, :xep:`92` is implemented by :mod:`aioxmpp.version`. Check
that module out for a more user-friendly way to handle things.
Next steps
==========
This quickstart should have given you an impression on how to use
:mod:`aioxmpp` for rather simple tasks. If you develop a complex application,
you might want to look into the more advanced topics in the following chapters
of the user guide.
docs/xso-query.rst 0000664 0000000 0000000 00000005073 14160146213 0014517 0 ustar 00root root 0000000 0000000 """
XSO Query Language
##################
Motivation
==========
In several situations, we want to have the ability to register callbacks for
only a subset of events. These events can generally be filtered with a
predicate.
It is inconvenient to write a filter function for each possible required
situation; even filter function templates get cumbersome at some point.
Syntax Draft
============
This is largely inspired by the syntax and semantics commonly found in ORMs. We
will have a less powerful expression language I’m afraid, but let’s see what we
can do::
Message.from_ == "fnord",
pubsub_xso.Event @ Message.xep0060_event,
pubsub_xso.EventItems @ pubsub_xso.Event.payload,
pubsub_xso.EventItems.node == "foo",
The ``@`` operator extracts an object of the LHS class from the descriptor on
the RHS. In subsequent statements, references to descriptors on that class refer
to the object extracted on the most recent extract having the class on the LHS.
The other operators work as normal.
Alternative::
Message.from_ == "fnord",
(Message.xep0060_event / pubsub_xso.Event.payload / pubsub_xso.EventItems.node
== "foo"),
The difficulty with this implementation is that we need a way to recover the
class to which the descriptor is bound from the descriptor. This requires either
a complete rewrite of the XSO module or a proxy which is returned when accessing
the descriptor via the class.
The proxy essentially breaks the isinstance checks we have throughout the test
code. The same would happen with a re-write though.
Specification
=============
`` / ``
Return all instances of `class` from the result set of `expr`
`` / ``
Return the union of the values of `descriptor` on all `class` instances
found in the result set of `expr`.
`` / `` (undetermined)
Return the union of the values of `descriptor` on all instances in the
result set of `expr` whose class has the `descriptor`.
Whether this semantic will be implemented is not determined yet.
``[constant]``
If `descriptor` is a mapping, return the union of the values with key
`constant` from the maps in `expr`.
``[integer constant]``
Return the n-th element from the result set of expr
``[where(subexpr)]``
Filter the result set of `expr`, excluding all elements where `subexpr` does
not evaluate to a true value.
Note that in the query language, ``[]`` binds less strong than ``/``. To
implement this, we will have to do some magic, but it should be implementable.
"""
examples/ 0000775 0000000 0000000 00000000000 14160146213 0012672 5 ustar 00root root 0000000 0000000 examples/.gitignore 0000664 0000000 0000000 00000000024 14160146213 0014656 0 ustar 00root root 0000000 0000000 pinstore.json
*.ini
examples/Makefile 0000664 0000000 0000000 00000000377 14160146213 0014341 0 ustar 00root root 0000000 0000000 BUILDUI=../utils/buildui.py -5
UIC_SOURCE_FILES=$(wildcard adhoc_browser/ui/*.ui)
UIC_PYTHON_FILES=$(patsubst %.ui,%.py,$(UIC_SOURCE_FILES))
all: $(UIC_PYTHON_FILES)
clean:
rm -rf $(UIC_PYTHON_FILES)
$(UIC_PYTHON_FILES): %.py: %.ui
$(BUILDUI) $< $@
examples/README.rst 0000664 0000000 0000000 00000011115 14160146213 0014360 0 ustar 00root root 0000000 0000000 aioxmpp Examples
################
Most of these examples are built on top of ``framework.py`` (also in this
directory). The only exceptions are those starting with ``quickstart_`` (they
are basically content of the quickstart guide and should be able to stand on
their own, serving as full examples) and ``xmpp_bridge.py``.
Those which use the examples framework share the following command line
options::
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
Configuration file to read
-j LOCAL_JID, --local-jid LOCAL_JID
JID to authenticate with (only required if not in
config)
-p Ask for password on stdio
-v Increase verbosity
``--config`` can point to an INI-style config file, which supports most notably
the following options, all of which are optional::
[global]
local_jid=
password=
pin_store=
pin_type=
* ``local_jid`` serves as fallback if the ``--local-jid`` command line argument
is not given. If neither is given, the JID is prompted for on the terminal.
* ``password`` is the password used for authentication. If this is missing, the
password is prompted for on the terminal.
* ``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.
In addition, some examples support additional configuration options, which are
listed below.
``muc_logger``
==============
::
[muc_logger]
muc_jid=
nick=
* ``muc_jid`` serves as fallback for the ``--muc`` option of that example. If
neither is given, the JID is prompted for on the terminal.
* ``nick`` serves as fallback for the ``--nick`` option of that example. If
neither is given, the nickname to use is prompted for on the terminal.
``get_muc_config``
==================
::
[muc_config]
muc_jid=
* ``muc_jid`` serves as fallback for the ``--muc`` option of that example. If
neither is given, the JID is prompted for on the terminal.
Note that this option is shared with ``get_muc_config``.
``set_muc_config``
==================
::
[muc_config]
muc_jid=
* ``muc_jid`` serves as fallback for the ``--muc`` option of that example. If
neither is given, the JID is prompted for on the terminal.
Note that this option is shared with ``get_muc_config``.
Running ``adhoc_browser``
=========================
To run ``adhoc_browser``, you need PyQt5 and you need to compile the Qt Designer
UI file to python code. For the latter, run::
make
in the examples directory. Now you can start the adhoc browser::
python3 -m adhoc_browser
You may pass additional command line arguments like you can for other examples.
``retrieve_avatar.py``
======================
``retrieve_avatar.py`` retrieves the PNG avatar of another user and
stores it in a file.
positional arguments:
==================== ===================================================
output_file the file the retrieved avatar image will be written
to.
==================== ===================================================
Additional optional argument:
--remote-jid REMOTE_JID
the jid of which to retrieve the avatar
The remote JID may also be supplied in the examples config file::
[avatar]
remote_jid=foo@example.com
If the remote JID is not given on the command line and also missing
from the config file ``retrieve_avatar.py`` will prompt for it.
``set_avatar.py``
=================
``set_avatar.py`` sets or unsets the avatar of the configured local
JID.
operations:
--set-avatar AVATAR_FILE
set the avatar to content of the supplied PNG file.
--wipe-avatar set the avatar to no avatar.
`get_vcard.py`
==============
``get_vcard.py`` gets the vCard for a remote JID.
Additional optional argument:
--remote-jid REMOTE_JID
the jid of which to retrieve the avatar
The remote JID may also be supplied in the examples config file::
[vcard]
remote_jid=foo@example.com
If the remote JID is not given on the command line and also missing
from the config file ``get_vcard.py`` will prompt for it.
examples/adhoc_browser/ 0000775 0000000 0000000 00000000000 14160146213 0015513 5 ustar 00root root 0000000 0000000 examples/adhoc_browser/__main__.py 0000664 0000000 0000000 00000002767 14160146213 0017621 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import gc
import sys
try:
import quamash
import PyQt5.Qt as Qt
except ImportError as exc:
print(exc, file=sys.stderr)
print("This example requires quamash and PyQt5.", file=sys.stderr)
from adhoc_browser.main import AdHocBrowser
qapp = Qt.QApplication(sys.argv)
qapp.setQuitOnLastWindowClosed(False)
asyncio.set_event_loop(quamash.QEventLoop(app=qapp))
loop = asyncio.get_event_loop()
try:
example = AdHocBrowser()
example.prepare_argparse()
example.configure()
loop.run_until_complete(example.run_example())
finally:
loop.close()
asyncio.set_event_loop(None)
del example, loop, qapp
gc.collect()
examples/adhoc_browser/execute.py 0000664 0000000 0000000 00000022624 14160146213 0017535 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: execute.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
# .
#
########################################################################
import asyncio
import html
import PyQt5.Qt as Qt
import aioxmpp.adhoc
from .utils import asyncify, asyncify_blocking
from .ui.form import Ui_FormDialog
class Executor(Qt.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.ui = Ui_FormDialog()
self.ui.setupUi(self)
self.base_title = self.windowTitle()
self.session = None
self.action_buttons = {
aioxmpp.adhoc.ActionType.NEXT: self.ui.btn_next,
aioxmpp.adhoc.ActionType.PREV: self.ui.btn_prev,
aioxmpp.adhoc.ActionType.CANCEL: self.ui.btn_cancel,
aioxmpp.adhoc.ActionType.COMPLETE: self.ui.btn_complete,
}
self.ui.btn_close.clicked.connect(self._close)
self.ui.btn_next.clicked.connect(self._next)
self.ui.btn_prev.clicked.connect(self._prev)
self.ui.btn_cancel.clicked.connect(self._cancel)
self.ui.btn_complete.clicked.connect(self._complete)
self.fieldmap = {}
self.form_area = None
def _close(self):
self.close()
def _next(self):
self._action(aioxmpp.adhoc.ActionType.NEXT)
def _prev(self):
self._action(aioxmpp.adhoc.ActionType.PREV)
def _cancel(self):
self.close()
def _complete(self):
self._action(aioxmpp.adhoc.ActionType.COMPLETE)
@asyncify_blocking
async def run_with_session_fut(self, name, session_fut):
self.setWindowTitle("{} - {}".format(
name,
self.base_title
))
self.show()
try:
self.session = await session_fut
except Exception as exc:
self.fail(exc)
return
self.response_received()
def _update_buttons(self):
status = self.session.status
if status != aioxmpp.adhoc.CommandStatus.EXECUTING:
self.ui.btn_cancel.hide()
self.ui.btn_next.hide()
self.ui.btn_prev.hide()
self.ui.btn_complete.hide()
self.ui.btn_close.show()
else:
self.ui.btn_close.hide()
allowed_actions = self.session.allowed_actions
for action, btn in self.action_buttons.items():
if action in allowed_actions:
btn.show()
else:
btn.hide()
def _update_notes(self):
notes = self.session.response.notes
if not notes:
self.ui.notes_area.hide()
return
self.ui.notes_area.show()
source_parts = []
for note in notes:
print(note.type_, note.body)
source_parts.append("
{}: {}
".format(
html.escape(note.type_.value),
"
".join(html.escape(note.body or "").split("\n"))
))
self.ui.notes_area.setText("\n".join(source_parts))
def _update_form(self):
payload = self.session.first_payload
if not isinstance(payload, aioxmpp.forms.Data):
self.ui.form_widget.hide()
return
self.ui.form_widget.show()
if payload.title:
self.ui.title.show()
self.ui.title.setText(payload.title)
else:
self.ui.title.hide()
if payload.instructions:
self.ui.instructions.show()
self.ui.instructions.setText(
"
{}
".format(
"
".join(map(html.escape, payload.instructions))
)
)
else:
self.ui.instructions.hide()
if self.form_area is not None:
self.ui.form_widget.layout().removeWidget(self.form_area)
self.ui.form_widget.children().remove(self.form_area)
self.form_area.deleteLater()
self.form_area = Qt.QScrollArea()
layout = Qt.QFormLayout()
self.form_area.setLayout(layout)
self.ui.form_widget.layout().addWidget(self.form_area)
self.fieldmap = {}
for field in payload.fields:
if field.var == "FORM_TYPE":
continue
label, widget = None, None
if field.type_ == aioxmpp.forms.FieldType.FIXED:
label = Qt.QLabel(field.values[0])
layout.addRow(
label
)
elif field.type_ in {aioxmpp.forms.FieldType.LIST_SINGLE,
aioxmpp.forms.FieldType.LIST_MULTI}:
label = Qt.QLabel(field.label)
widget = Qt.QListWidget()
for opt_value, opt_label in sorted(field.options.items()):
item = Qt.QListWidgetItem(opt_label, widget)
item.setData(Qt.Qt.UserRole, opt_value)
widget.addItem(item)
if field.type_.is_multivalued:
widget.setSelectionMode(
Qt.QAbstractItemView.MultiSelection
)
else:
widget.setSelectionMode(
Qt.QAbstractItemView.SingleSelection
)
layout.addRow(label, widget)
elif field.type_ in {aioxmpp.forms.FieldType.TEXT_SINGLE,
aioxmpp.forms.FieldType.JID_SINGLE}:
label = Qt.QLabel(field.label)
widget = Qt.QLineEdit()
if field.values:
widget.setText(field.values[0])
layout.addRow(label, widget)
elif field.type_ in {aioxmpp.forms.FieldType.TEXT_PRIVATE}:
label = Qt.QLabel(field.label)
widget = Qt.QLineEdit()
widget.setEchoMode(Qt.QLineEdit.Password)
widget.setInputMethodHints(Qt.Qt.ImhHiddenText |
Qt.Qt.ImhNoPredictiveText |
Qt.Qt.ImhNoAutoUppercase)
if field.values:
widget.setText(field.values[0])
layout.addRow(label, widget)
elif field.type_ in {aioxmpp.forms.FieldType.TEXT_MULTI,
aioxmpp.forms.FieldType.JID_MULTI}:
label = Qt.QLabel(field.label)
widget = Qt.QTextEdit()
widget.setText("\n".join(field.values))
widget.setAcceptRichText(False)
layout.addRow(label, widget)
else:
self.fail("unhandled field type: {}".format(field.type_))
self.fieldmap[field.var] = label, widget
def _fill_form(self):
for field in self.session.first_payload.fields:
try:
_, widget = self.fieldmap[field.var]
except KeyError:
continue
if widget is None:
continue
if field.type_ in {aioxmpp.forms.FieldType.LIST_SINGLE,
aioxmpp.forms.FieldType.LIST_MULTI}:
# widget is list widget
selected = []
for index in widget.selectionModel().selectedIndexes():
selected.append(
widget.model().data(
index,
Qt.Qt.UserRole
)
)
field.values[:] = selected
elif field.type_ in {aioxmpp.forms.FieldType.JID_SINGLE,
aioxmpp.forms.FieldType.TEXT_SINGLE,
aioxmpp.forms.FieldType.TEXT_PRIVATE}:
# widget is line edit
field.values[:] = [widget.text()]
elif field.type_ in {aioxmpp.forms.FieldType.JID_MULTI,
aioxmpp.forms.FieldType.TEXT_MULTI}:
field.values[:] = widget.toPlainText().split("\n")
def _action(self, type_):
self._fill_form()
self._submit_action(type_)
@asyncify_blocking
async def _submit_action(self, type_):
await self.session.proceed(action=type_)
self.response_received()
def response_received(self):
try:
self.ui.status.setText(str(self.session.status))
self._update_buttons()
self._update_notes()
self._update_form()
except Exception as exc:
self.fail(str(exc))
def fail(self, message):
Qt.QMessageBox.critical(
self.parent(),
"Error",
message,
)
self.close()
@asyncify
async def closeEvent(self, ev):
if self.session is not None:
await self.session.close()
return super().closeEvent(ev)
examples/adhoc_browser/main.py 0000664 0000000 0000000 00000015457 14160146213 0017025 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import sys
import PyQt5.Qt as Qt
import aioxmpp
from framework import Example
from .utils import asyncify_blocking
try:
from .ui.main import Ui_MainWindow
except ImportError:
print("You didn’t run make, I’ll try to do it for you...",
file=sys.stderr)
import subprocess
try:
subprocess.check_call(["make"])
from .ui.main import Ui_MainWindow
except Exception:
print("Nope, that didn’t work out. "
"You’ll have to fix that yourself. Sorry.",
file=sys.stderr)
raise
from .execute import Executor
if not hasattr(asyncio, "async"):
setattr(asyncio, "async", asyncio.ensure_future)
class DiscoItemsModel(Qt.QAbstractTableModel):
COLUMN_NAME = 0
COLUMN_JID = 1
COLUMN_NODE = 2
COLUMN_COUNT = 3
def __init__(self, parent=None):
super().__init__(parent)
self._items = []
def replace(self, items):
self.beginResetModel()
self._items[:] = items
self.endResetModel()
def rowCount(self, parent):
if parent.isValid():
return 0
return len(self._items)
def columnCount(self, parent):
return self.COLUMN_COUNT
def data(self, index, role):
if role != Qt.Qt.DisplayRole:
return
if not index.isValid():
return
item = self._items[index.row()]
return {
self.COLUMN_NAME: item.name,
self.COLUMN_JID: str(item.jid),
self.COLUMN_NODE: item.node or "",
}.get(index.column())
def headerData(self, section, orientation, role):
if orientation != Qt.Qt.Horizontal:
return
if role != Qt.Qt.DisplayRole:
return
return {
self.COLUMN_NAME: "Name",
self.COLUMN_JID: "JID",
self.COLUMN_NODE: "Node",
}.get(section)
class MainWindow(Qt.QMainWindow):
def __init__(self, close_fut):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.ui.btn_scan.clicked.connect(
self.scan
)
self.disco_model = DiscoItemsModel()
self.ui.disco_items.setModel(self.disco_model)
self.ui.disco_items.activated.connect(self.disco_item_activated)
self.commands_model = DiscoItemsModel()
self.sorted_commands_model = Qt.QSortFilterProxyModel()
self.sorted_commands_model.setSourceModel(self.commands_model)
self.sorted_commands_model.setDynamicSortFilter(True)
self.sorted_commands_model.setSortRole(Qt.Qt.DisplayRole)
self.sorted_commands_model.setSortCaseSensitivity(Qt.Qt.CaseInsensitive)
self.sorted_commands_model.sort(0, Qt.Qt.AscendingOrder)
self.ui.commands.setModel(self.sorted_commands_model)
self.ui.commands.activated.connect(self.command_activated)
self._close_fut = close_fut
def disco_item_activated(self, index):
if not index.isValid():
return
jid = self.disco_model.data(
self.disco_model.index(
index.row(),
self.disco_model.COLUMN_JID,
index.parent(),
),
Qt.Qt.DisplayRole)
self.ui.target_jid.setText(jid)
self.scan()
@asyncify_blocking
async def command_activated(self, index):
if not index.isValid():
return
jid = aioxmpp.JID.fromstr(
self.sorted_commands_model.data(
self.sorted_commands_model.index(
index.row(),
self.disco_model.COLUMN_JID,
index.parent(),
),
Qt.Qt.DisplayRole)
)
node = self.sorted_commands_model.data(
self.sorted_commands_model.index(
index.row(),
self.disco_model.COLUMN_NODE,
index.parent(),
),
Qt.Qt.DisplayRole)
session_fut = asyncio.ensure_future(
self.adhoc_svc.execute(jid, node)
)
dlg = Executor(self)
dlg.run_with_session_fut(node, session_fut)
@asyncify_blocking
async def scan(self, *args, **kwargs):
jid = aioxmpp.JID.fromstr(self.ui.target_jid.text())
items = await self.disco_svc.query_items(
jid
)
commands = await self.adhoc_svc.get_commands(
jid
)
self.disco_model.replace(items.items)
self.commands_model.replace(commands)
def closeEvent(self, ev):
if self._close_fut.done():
return super().closeEvent(ev)
self._close_fut.set_result(None)
class AdHocBrowser(Example):
def __init__(self):
super().__init__()
self._close = asyncio.Future()
self._mainwindow = MainWindow(self._close)
async def _main(self):
self._mainwindow.client = self.client
self._mainwindow.disco_svc = self.disco_svc
self._mainwindow.adhoc_svc = self.adhoc_svc
self._mainwindow.show()
try:
await self._close
except:
self._mainwindow.close()
def _established(self):
self._mainwindow.statusBar().showMessage(
"connected as {}".format(self.client.local_jid)
)
if not self._mainwindow.ui.target_jid.text():
self._mainwindow.ui.target_jid.setText(
str(self.client.local_jid.domain)
)
async def run_example(self):
def kill(*args):
nonlocal task
if task is not None:
task.cancel()
self.client = self.make_simple_client()
self.disco_svc = self.client.summon(aioxmpp.DiscoClient)
self.adhoc_svc = self.client.summon(aioxmpp.AdHocClient)
self.client.on_failure.connect(kill)
self.client.on_stream_established.connect(self._established)
async with self.client.connected():
task = asyncio.ensure_future(self._main())
await task
examples/adhoc_browser/ui/ 0000775 0000000 0000000 00000000000 14160146213 0016130 5 ustar 00root root 0000000 0000000 examples/adhoc_browser/ui/.gitignore 0000664 0000000 0000000 00000000005 14160146213 0020113 0 ustar 00root root 0000000 0000000 *.py
examples/adhoc_browser/ui/form.ui 0000664 0000000 0000000 00000006006 14160146213 0017434 0 ustar 00root root 0000000 0000000
FormDialog00575592Execute Ad-Hoc CommandTextLabel00Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse75trueTextLabelTextLabelQt::Horizontal4020PreviousNextfalseCompleteCancelClose
examples/adhoc_browser/ui/main.ui 0000664 0000000 0000000 00000007112 14160146213 0017414 0 ustar 00root root 0000000 0000000
MainWindow00800600Ad-Hoc Command (XEP-0050) BrowserQLayout::SetDefaultConstraintTarget JIDScan for commands00Qt::HorizontalCommands at target (activate to execute)2Other services nearby (activate to scan)QAbstractItemView::SingleSelectionQAbstractItemView::SelectRowsfalseQt::SolidLinefalsefalsefalsefalse0080025
examples/adhoc_browser/utils.py 0000664 0000000 0000000 00000004207 14160146213 0017230 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
import asyncio
import functools
import PyQt5.Qt as Qt
def asyncified_done(parent, task):
try:
task.result()
except asyncio.CancelledError:
pass
except Exception as exc:
if parent is not None:
Qt.QMessageBox.critical(
parent,
"Job failed",
str(exc),
)
def asyncified_unblock(dlg, cursor, task):
dlg.setCursor(cursor)
dlg.setEnabled(True)
def asyncify(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
task = asyncio.ensure_future(fn(*args, **kwargs))
task.add_done_callback(functools.partial(asyncified_done, None))
return wrapper
def asyncify_blocking(fn):
@functools.wraps(fn)
def wrapper(self, *args, **kwargs):
prev_cursor = self.cursor()
self.setEnabled(False)
self.setCursor(Qt.Qt.WaitCursor)
try:
task = asyncio.ensure_future(fn(self, *args, **kwargs))
except:
self.setEnabled(True)
self.setCursor(prev_cursor)
raise
task.add_done_callback(functools.partial(
asyncified_done,
self))
task.add_done_callback(functools.partial(
asyncified_unblock,
self, prev_cursor))
return wrapper
examples/block_jid.py 0000775 0000000 0000000 00000007360 14160146213 0015175 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
########################################################################
# File name: block_jid.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
# .
#
########################################################################
import asyncio
import sys
import aioxmpp.disco
import aioxmpp.blocking
import aioxmpp.xso
from framework import Example, exec_example
class BlockJID(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"--add",
dest="jids_to_block",
default=[],
action="append",
type=jid,
metavar="JID",
help="JID to block (can be specified multiple times)",
)
self.argparse.add_argument(
"--remove",
dest="jids_to_unblock",
default=[],
action="append",
type=jid,
metavar="JID",
help="JID to unblock (can be specified multiple times)",
)
self.argparse.add_argument(
"-l", "--list",
action="store_true",
default=False,
dest="show_list",
help="If given, prints the block list at the end of the operation",
)
def configure(self):
super().configure()
if not (self.args.jids_to_block or
self.args.show_list or
self.args.jids_to_unblock):
print("nothing to do!", file=sys.stderr)
print("specify --add and/or --remove and/or --list",
file=sys.stderr)
sys.exit(1)
def make_simple_client(self):
client = super().make_simple_client()
self.blocking = client.summon(aioxmpp.BlockingClient)
return client
async def run_simple_example(self):
# we are polite and ask the server whether it actually supports the
# XEP-0191 block list protocol
disco = self.client.summon(aioxmpp.DiscoClient)
server_info = await disco.query_info(
self.client.local_jid.replace(
resource=None,
localpart=None,
)
)
if "urn:xmpp:blocking" not in server_info.features:
print("server does not support block lists!", file=sys.stderr)
sys.exit(2)
# now that we are sure that the server supports it, we can send
# requests.
if self.args.jids_to_block:
await self.blocking.block_jids(self.args.jids_to_block)
else:
print("nothing to block")
if self.args.jids_to_unblock:
await self.blocking.unblock_jids(self.args.jids_to_unblock)
else:
print("nothing to unblock")
if self.args.show_list:
# print all the items; again, .items is a list of JIDs
print("current block list:")
for item in sorted(self.blocking.blocklist):
print("\t", item, sep="")
if __name__ == "__main__":
exec_example(BlockJID())
examples/carbons_sniffer.py 0000664 0000000 0000000 00000004644 14160146213 0016417 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: carbons_sniffer.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
# .
#
########################################################################
import asyncio
import aioxmpp
from framework import Example, exec_example
class CarbonsSniffer(Example):
def make_simple_client(self):
client = super().make_simple_client()
self.carbons = client.summon(aioxmpp.CarbonsClient)
return client
def _format_message(self, message):
parts = []
parts.append(str(message))
if message.body:
parts.append("text: {}".format(message.body))
else:
parts.append("other")
return "; ".join(parts)
def _message_filter(self, message):
if (message.from_ != self.client.local_jid.bare() and
message.from_ is not None):
return
if message.xep0280_sent is not None:
print("SENT: {}".format(self._format_message(
message.xep0280_sent.stanza
)))
elif message.xep0280_received is not None:
print("RECV: {}".format(self._format_message(
message.xep0280_received.stanza
)))
async def run_example(self):
self.stop_event = self.make_sigint_event()
await super().run_example()
async def run_simple_example(self):
filterchain = self.client.stream.app_inbound_message_filter
with filterchain.context_register(self._message_filter):
print("enabling carbons")
await self.carbons.enable()
print("carbons enabled! sniffing ... (hit Ctrl+C to stop)")
await self.stop_event.wait()
if __name__ == "__main__":
exec_example(CarbonsSniffer())
examples/echo_bot.py 0000664 0000000 0000000 00000003513 14160146213 0015030 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: echo_bot.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
# .
#
########################################################################
import asyncio
import aioxmpp
from framework import Example, exec_example
class EchoBot(Example):
def message_received(self, msg):
if msg.type_ != aioxmpp.MessageType.CHAT:
return
if not msg.body:
# do not reflect anything without a body
return
# we could also use reply = msg.make_reply() instead
reply = aioxmpp.Message(
type_=msg.type_,
to=msg.from_,
)
# make_reply() would not set the body though
reply.body.update(msg.body)
self.client.enqueue(reply)
async def run_simple_example(self):
stop_event = self.make_sigint_event()
self.client.stream.register_message_callback(
aioxmpp.MessageType.CHAT,
None,
self.message_received,
)
print("echoing... (press Ctrl-C or send SIGTERM to stop)")
await stop_event.wait()
if __name__ == "__main__":
exec_example(EchoBot())
examples/echo_bot_im.py 0000664 0000000 0000000 00000004140 14160146213 0015512 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: echo_bot.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
# .
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.im.dispatcher
from framework import Example, exec_example
class EchoBot(Example):
def message_received(self, msg, peer, sent, source):
if sent: # ignore mesasges we sent
return msg
if msg.type_ != aioxmpp.MessageType.CHAT:
return msg
if not msg.body:
# do not reflect anything without a body
return
# we could also use reply = msg.make_reply() instead
reply = aioxmpp.Message(
type_=msg.type_,
to=msg.from_,
)
# make_reply() would not set the body though
reply.body.update(msg.body)
self.client.enqueue(reply)
def make_simple_client(self):
client = super().make_simple_client()
self.dispatcher = client.summon(
aioxmpp.im.dispatcher.IMDispatcher
)
return client
async def run_simple_example(self):
stop_event = self.make_sigint_event()
self.dispatcher.message_filter.register(
self.message_received,
0,
)
print("echoing... (press Ctrl-C or send SIGTERM to stop)")
await stop_event.wait()
if __name__ == "__main__":
exec_example(EchoBot())
examples/entity_info.py 0000664 0000000 0000000 00000006765 14160146213 0015611 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: entity_info.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
# .
#
########################################################################
import asyncio
import itertools
import aioxmpp.disco
import aioxmpp.forms
from framework import Example, exec_example
class ServerInfo(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"target_entity",
default=None,
nargs="?",
type=jid,
help="Entity to query (leave empty to query account)"
)
self.argparse.add_argument(
"--node",
dest="target_node",
default=None,
help="disco node to query"
)
async def run_simple_example(self):
disco = self.client.summon(aioxmpp.DiscoClient)
try:
info = await disco.query_info(
self.args.target_entity or self.client.local_jid.bare(),
node=self.args.target_node,
timeout=10
)
except Exception as exc:
print("could not get info: ")
print("{}: {}".format(type(exc).__name__, exc))
raise
print("features:")
for feature in info.features:
print(" {!r}".format(feature))
print("identities:")
identities = list(info.identities)
def identity_key(ident):
return (ident.category, ident.type_)
identities.sort(key=identity_key)
for (category, type_), identities in (
itertools.groupby(info.identities, identity_key)):
print(" category={!r} type={!r}".format(category, type_))
subidentities = list(identities)
subidentities.sort(key=lambda ident: ident.lang)
for identity in subidentities:
print(" [{}] {!r}".format(identity.lang, identity.name))
print("extensions:")
for ext in info.exts:
print(" ", ext.get_form_type())
for field in ext.fields:
if (field.var == "FORM_TYPE" and
field.type_ == aioxmpp.forms.xso.FieldType.HIDDEN):
continue
print(" var={!r} values=".format(field.var), end="")
if len(field.values) == 1:
print("{!r}".format([field.values[0]]))
elif len(field.values) == 0:
print("[]")
else:
print("[")
for value in field.values:
print(" {!r}".format(value))
print(" ]")
if __name__ == "__main__":
exec_example(ServerInfo())
examples/entity_items.py 0000664 0000000 0000000 00000004010 14160146213 0015754 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: entity_items.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
# .
#
########################################################################
import asyncio
import itertools
import aioxmpp.disco
from framework import Example, exec_example
class EntityItems(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"target_entity",
type=jid
)
self.argparse.add_argument(
"target_node",
default=None,
nargs="?",
)
async def run_simple_example(self):
disco = self.client.summon(aioxmpp.DiscoClient)
try:
items = await disco.query_items(
self.args.target_entity,
node=self.args.target_node,
timeout=10
)
except Exception as exc:
print("could not get info: ")
print("{}: {}".format(type(exc).__name__, exc))
raise
print("items:")
for item in items.items:
print(" jid={} node={!r} name={!r}".format(item.jid, item.node, item.name))
if __name__ == "__main__":
exec_example(EntityItems())
examples/framework.py 0000664 0000000 0000000 00000015153 14160146213 0015246 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: framework.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
# .
#
########################################################################
import abc
import argparse
import asyncio
import configparser
import getpass
import json
import logging
import logging.config
import os
import os.path
import signal
import sys
try:
import readline # NOQA
except ImportError:
pass
import aioxmpp
class Example(metaclass=abc.ABCMeta):
def __init__(self):
super().__init__()
self.argparse = argparse.ArgumentParser()
def prepare_argparse(self):
config_default_path = os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
"aioxmpp_examples.ini")
if not os.path.exists(config_default_path):
config_default_path = None
self.argparse.add_argument(
"-c", "--config",
default=config_default_path,
type=argparse.FileType("r"),
help="Configuration file to read",
)
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"-j", "--local-jid",
type=jid,
help="JID to authenticate with (only required if not in config)"
)
mutex = self.argparse.add_mutually_exclusive_group()
mutex.add_argument(
"-p",
dest="ask_password",
action="store_true",
default=False,
help="Ask for password on stdio"
)
mutex.add_argument(
"-A",
nargs="?",
dest="anonymous",
default=False,
help="Perform ANONYMOUS authentication"
)
self.argparse.add_argument(
"-v",
help="Increase verbosity (this has no effect if a logging config"
" file is specified in the config file)",
default=0,
dest="verbosity",
action="count",
)
def configure(self):
self.args = self.argparse.parse_args()
self.config = configparser.ConfigParser()
if self.args.config is not None:
with self.args.config:
self.config.read_file(self.args.config)
if self.config.has_option("global", "logging"):
logging.config.fileConfig(
self.config.get("global", "logging")
)
else:
logging.basicConfig(
level={
0: logging.ERROR,
1: logging.WARNING,
2: logging.INFO,
}.get(self.args.verbosity, logging.DEBUG)
)
self.g_jid = self.args.local_jid
if self.g_jid is None:
try:
self.g_jid = aioxmpp.JID.fromstr(
self.config.get("global", "local_jid"),
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.g_jid = aioxmpp.JID.fromstr(
input("Account JID> ")
)
if self.config.has_option("global", "pin_store"):
with open(self.config.get("global", "pin_store")) as f:
pin_store = json.load(f)
pin_type = aioxmpp.security_layer.PinType(
self.config.getint("global", "pin_type", fallback=0)
)
else:
pin_store = None
pin_type = None
anonymous = self.args.anonymous
if anonymous is False:
if self.args.ask_password:
password = getpass.getpass()
else:
try:
jid_sect = str(self.g_jid)
if jid_sect not in self.config:
jid_sect = "global"
password = self.config.get(jid_sect, "password")
except (configparser.NoOptionError,
configparser.NoSectionError):
logging.error(('When the local JID %s is set, password ' +
'must be set as well.') % str(self.g_jid))
raise
else:
password = None
anonymous = anonymous or ""
no_verify = self.config.getboolean(
str(self.g_jid), "no_verify",
fallback=self.config.getboolean("global", "no_verify",
fallback=False)
)
logging.info(
"constructing security layer with "
"pin_store=%r, "
"pin_type=%r, "
"anonymous=%r, "
"no_verify=%r, "
"not-None password %s",
pin_store,
pin_type,
anonymous,
no_verify,
password is not None,
)
self.g_security_layer = aioxmpp.make_security_layer(
password,
pin_store=pin_store,
pin_type=pin_type,
anonymous=anonymous,
no_verify=no_verify,
)
def make_simple_client(self):
return aioxmpp.PresenceManagedClient(
self.g_jid,
self.g_security_layer,
)
def make_sigint_event(self):
event = asyncio.Event()
loop = asyncio.get_event_loop()
loop.add_signal_handler(
signal.SIGINT,
event.set,
)
return event
async def run_simple_example(self):
raise NotImplementedError(
"run_simple_example must be overridden if run_example isn’t"
)
async def run_example(self):
self.client = self.make_simple_client()
async with self.client.connected():
await self.run_simple_example()
def exec_example(example):
example.prepare_argparse()
example.configure()
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(example.run_example())
finally:
loop.close()
examples/get_external_services.py 0000664 0000000 0000000 00000003767 14160146213 0017645 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: get_external_services.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
# .
#
########################################################################
import asyncio
import itertools
import aioxmpp.extservice
import aioxmpp.forms
from framework import Example, exec_example
class ExternalServices(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"target_entity",
default=None,
nargs="?",
type=jid,
help="Entity to query (leave empty to query account)"
)
async def run_simple_example(self):
services = await aioxmpp.extservice.get_external_services(
self.client,
self.args.target_entity,
)
for svc in services.services:
print("Service:")
print(f" type={svc.type_!r}")
print(f" transport={svc.transport!r}")
print(f" host={svc.host!r}")
print(f" port={svc.port!r}")
print(f" username={svc.username!r}")
print(f" password={svc.password!r}")
if __name__ == "__main__":
exec_example(ExternalServices())
examples/get_muc_affiliations.py 0000664 0000000 0000000 00000004607 14160146213 0017426 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: get_muc_config.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
# .
#
########################################################################
import asyncio
import configparser
import aioxmpp.muc
import aioxmpp.muc.xso
from framework import Example, exec_example
class ServerInfo(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"--muc",
type=jid,
default=None,
help="JID of the muc to query"
)
self.argparse.add_argument(
"--level",
default="member",
help="Affiliation level to query"
)
def configure(self):
super().configure()
self.muc_jid = self.args.muc
if self.muc_jid is None:
try:
self.muc_jid = aioxmpp.JID.fromstr(
self.config.get("muc_config", "muc_jid")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.muc_jid = aioxmpp.JID.fromstr(
input("MUC JID> ")
)
def make_simple_client(self):
client = super().make_simple_client()
client.summon(aioxmpp.MUCClient)
return client
async def run_simple_example(self):
addrs = await self.client.summon(aioxmpp.MUCClient).get_affiliated(
self.muc_jid,
self.args.level,
)
for addr in addrs:
print(addr)
if __name__ == "__main__":
exec_example(ServerInfo())
examples/get_muc_config.py 0000664 0000000 0000000 00000005006 14160146213 0016215 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: get_muc_config.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
# .
#
########################################################################
import asyncio
import configparser
import aioxmpp.muc
import aioxmpp.muc.xso
from framework import Example, exec_example
class ServerInfo(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"--muc",
type=jid,
default=None,
help="JID of the muc to query"
)
def configure(self):
super().configure()
self.muc_jid = self.args.muc
if self.muc_jid is None:
try:
self.muc_jid = aioxmpp.JID.fromstr(
self.config.get("muc_config", "muc_jid")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.muc_jid = aioxmpp.JID.fromstr(
input("MUC JID> ")
)
def make_simple_client(self):
client = super().make_simple_client()
client.summon(aioxmpp.MUCClient)
return client
async def run_simple_example(self):
config = await self.client.summon(
aioxmpp.MUCClient
).get_room_config(
self.muc_jid
)
form = aioxmpp.muc.xso.ConfigurationForm.from_xso(config)
print("name:", form.roomname.value)
print("description:", form.roomdesc.value)
print("moderated?", form.moderatedroom.value)
print("members only?", form.membersonly.value)
print("persistent?", form.persistentroom.value)
if __name__ == "__main__":
exec_example(ServerInfo())
examples/get_vcard.py 0000664 0000000 0000000 00000004574 14160146213 0015214 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: get_vcard.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
# .
#
########################################################################
import asyncio
import lxml
import aioxmpp
import aioxmpp.vcard as vcard
from framework import Example, exec_example
class VCard(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"--remote-jid",
type=jid,
help="the jid of which to retrieve the avatar"
)
def configure(self):
super().configure()
self.remote_jid = self.args.remote_jid
if self.remote_jid is None:
try:
self.remote_jid = aioxmpp.JID.fromstr(
self.config.get("vcard", "remote_jid")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.remote_jid = aioxmpp.JID.fromstr(
input("Remote JID> ")
)
def make_simple_client(self):
client = super().make_simple_client()
self.vcard = client.summon(aioxmpp.vcard.VCardService)
return client
async def run_simple_example(self):
vcard = await self.vcard.get_vcard(
self.remote_jid
)
es = lxml.etree.tostring(vcard.elements, pretty_print=True,
encoding="utf-8")
print(es.decode("utf-8"))
async def run_example(self):
await super().run_example()
if __name__ == "__main__":
exec_example(VCard())
examples/ibr_test.py 0000664 0000000 0000000 00000006020 14160146213 0015055 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: ibr_test.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
# .
#
########################################################################
import asyncio
import getpass
try:
import readline
except ImportError:
pass
import aioxmpp
import aioxmpp.ibr as ibr
async def get_fields(jid):
metadata = aioxmpp.make_security_layer(None)
_, stream, features = await aioxmpp.node.connect_xmlstream(jid, metadata)
reply = await ibr.get_registration_fields(stream)
print(ibr.get_used_fields(reply))
async def get_info(jid, password):
client = aioxmpp.PresenceManagedClient(
jid,
aioxmpp.make_security_layer(password)
)
async with client.connected() as stream:
service = ibr.RegistrationService(stream)
reply = await service.get_client_info()
print("Username: " + reply.username)
async def register(jid, password):
metadata = aioxmpp.make_security_layer(None)
_, stream, features = await aioxmpp.node.connect_xmlstream(jid, metadata)
query = ibr.Query(jid.localpart, password)
await ibr.register(stream, query)
print("Registered")
async def change_password(jid, old_password, new_password):
client = aioxmpp.PresenceManagedClient(
jid,
aioxmpp.make_security_layer(old_password)
)
async with client.connected() as stream:
service = ibr.RegistrationService(stream)
await service.change_pass(new_password)
print("Password changed")
async def cancel(jid, password):
client = aioxmpp.PresenceManagedClient(
jid,
aioxmpp.make_security_layer(password)
)
async with client.connected() as stream:
service = ibr.RegistrationService(stream)
await service.cancel_registration()
if __name__ == "__main__":
local_jid = aioxmpp.JID.fromstr(input("local JID> "))
password = getpass.getpass()
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(cancel(local_jid, password))
except:
pass
loop.run_until_complete(get_fields(local_jid, ))
loop.run_until_complete(register(local_jid, password))
loop.run_until_complete(get_info(local_jid, password))
loop.run_until_complete(change_password(local_jid, password, "aaa"))
try:
loop.run_until_complete(cancel(local_jid, "aaa"))
except:
pass
examples/list_adhoc_commands.py 0000664 0000000 0000000 00000003654 14160146213 0017246 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: list_adhoc_commands.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
# .
#
########################################################################
import asyncio
import aioxmpp.adhoc
from framework import Example, exec_example
class ListAdhocCommands(Example):
def prepare_argparse(self):
super().prepare_argparse()
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"peer_jid",
nargs="?",
metavar="JID",
default=None,
help="Entity to ask for ad-hoc commands. Must be a full jid,"
" defaults to the domain of the local JID (asking the server)"
)
def configure(self):
super().configure()
self.adhoc_peer_jid = (
self.args.peer_jid or
self.g_jid.replace(resource=None, localpart=None)
)
async def run_simple_example(self):
adhoc = self.client.summon(aioxmpp.adhoc.AdHocClient)
for item in await adhoc.get_commands(self.adhoc_peer_jid):
print("{}: {}".format(
item.node,
item.name
))
if __name__ == "__main__":
exec_example(ListAdhocCommands())
examples/list_presence.py 0000664 0000000 0000000 00000005664 14160146213 0016116 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: list_presence.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
# .
#
########################################################################
import asyncio
from datetime import timedelta
import aioxmpp.presence
from framework import Example, exec_example
class PresenceCollector:
def __init__(self, done_timeout=timedelta(seconds=1)):
self.presences = []
self.done_future = asyncio.Future()
self.done_timeout = done_timeout
self._reset_timer()
def _reset_timer(self):
self._done_task = asyncio.ensure_future(
asyncio.sleep(self.done_timeout.total_seconds())
)
self._done_task.add_done_callback(self._sleep_done)
def _sleep_done(self, task):
try:
task.result()
except asyncio.CancelledError:
return
self.done_future.set_result(self.presences)
def add_presence(self, pres):
self.presences.append(pres)
self._done_task.cancel()
self._reset_timer()
class ListPresence(Example):
def make_simple_client(self):
client = super().make_simple_client()
self.collector = PresenceCollector()
client.stream.register_presence_callback(
aioxmpp.PresenceType.AVAILABLE,
None,
self.collector.add_presence,
)
client.stream.register_presence_callback(
aioxmpp.PresenceType.UNAVAILABLE,
None,
self.collector.add_presence,
)
return client
async def run_simple_example(self):
print("collecting presences... ")
self.presences = await self.collector.done_future
async def run_example(self):
await super().run_example()
print("found presences:")
for i, pres in enumerate(self.presences):
print("presence {}".format(i))
print(" peer: {}".format(pres.from_))
print(" type: {}".format(pres.type_))
print(" show: {}".format(pres.show))
print(" status: ")
for lang, text in pres.status.items():
print(" (lang={}) {!r}".format(
lang,
text))
if __name__ == "__main__":
exec_example(ListPresence())
examples/listen_pep.py 0000664 0000000 0000000 00000004761 14160146213 0015416 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: listen_pep.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
# .
#
########################################################################
import asyncio
import functools
import itertools
import io
import pathlib
import aioxmpp.disco
import aioxmpp.entitycaps
import aioxmpp.presence
import aioxmpp.xso
from framework import Example, exec_example
class ListenPEP(Example):
def prepare_argparse(self):
super().prepare_argparse()
self.argparse.add_argument(
"--namespace",
dest="namespaces",
default=[],
action="append",
metavar="PEP-NAMESPACE",
help="PEP namespace to listen for (omit the +notify!). May be "
"given multiple times."
)
def configure(self):
super().configure()
self.pep_namespaces = self.args.namespaces
def _on_item_published(self, jid, node, item, message=None):
buf = io.BytesIO()
aioxmpp.xml.write_single_xso(item, buf)
print(jid, node, buf.getvalue().decode("utf-8"))
def make_simple_client(self):
client = super().make_simple_client()
self.caps = client.summon(aioxmpp.EntityCapsService)
self.pep = client.summon(aioxmpp.PEPClient)
self.claims = []
for ns in self.pep_namespaces:
claim = self.pep.claim_pep_node(
ns,
notify=True,
)
claim.on_item_publish.connect(self._on_item_published)
self.claims.append(claim)
return client
async def run_example(self):
self.stop_event = self.make_sigint_event()
await super().run_example()
async def run_simple_example(self):
await self.stop_event.wait()
if __name__ == "__main__":
exec_example(ListenPEP())
examples/muc_logger.py 0000664 0000000 0000000 00000013022 14160146213 0015365 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: muc_logger.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
# .
#
########################################################################
import asyncio
import configparser
import locale
from datetime import datetime
import aioxmpp.muc
import aioxmpp.structs
from framework import Example, exec_example
class MucLogger(Example):
def prepare_argparse(self):
super().prepare_argparse()
language_name = locale.getlocale()[0]
if language_name == "C":
language_name = "en-gb"
def language_range(s):
return aioxmpp.structs.LanguageRange.fromstr(
s.replace("_", "-")
)
default_language = language_range(language_name)
self.argparse.add_argument(
"--language",
default=language_range(language_name),
type=language_range,
help="Preferred language: if messages are sent with "
"multiple languages, this is the language shown "
"(default: {})".format(default_language),
)
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"--muc",
type=jid,
default=None,
help="JID of the muc to join"
)
self.argparse.add_argument(
"--nick",
default=None,
help="Nick name to use"
)
def configure(self):
super().configure()
self.language_selectors = [
self.args.language,
aioxmpp.structs.LanguageRange.fromstr("*")
]
self.muc_jid = self.args.muc
if self.muc_jid is None:
try:
self.muc_jid = aioxmpp.JID.fromstr(
self.config.get("muc_logger", "muc_jid")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.muc_jid = aioxmpp.JID.fromstr(
input("MUC JID> ")
)
self.muc_nick = self.args.nick
if self.muc_nick is None:
try:
self.muc_nick = self.config.get("muc_logger", "nick")
except (configparser.NoSectionError,
configparser.NoOptionError):
self.muc_nick = input("Nickname> ")
def make_simple_client(self):
client = super().make_simple_client()
muc = client.summon(aioxmpp.MUCClient)
room, self.room_future = muc.join(
self.muc_jid,
self.muc_nick
)
room.on_message.connect(self._on_message)
room.on_topic_changed.connect(self._on_topic_changed)
room.on_enter.connect(self._on_enter)
room.on_exit.connect(self._on_exit)
room.on_leave.connect(self._on_leave)
room.on_join.connect(self._on_join)
return client
def _on_message(self, message, member, source, **kwargs):
print("{} {}: {}".format(
datetime.utcnow().isoformat(),
member.nick,
message.body.lookup(self.language_selectors),
))
def _on_topic_changed(self, member, new_topic, *, muc_nick=None, **kwargs):
print("{} *** topic set by {}: {}".format(
datetime.utcnow().isoformat(),
member.nick if member is not None else muc_nick,
new_topic.lookup(self.language_selectors),
))
def _on_enter(self, presence, occupant, **kwargs):
print("{} *** entered room {}".format(
datetime.utcnow().isoformat(),
presence.from_.bare(),
))
def _on_exit(self, **kwargs):
print("{} *** left room".format(
datetime.utcnow().isoformat(),
))
def _on_join(self, member, **kwargs):
print("{} *** {} [{}] entered room".format(
datetime.utcnow().isoformat(),
member.nick,
member.direct_jid,
))
def _on_leave(self, member, muc_leave_mode=None, **kwargs):
print("{} *** {} [{}] left room ({})".format(
datetime.utcnow().isoformat(),
member.nick,
member.direct_jid,
muc_leave_mode,
))
async def run_example(self):
self.stop_event = self.make_sigint_event()
await super().run_example()
async def run_simple_example(self):
print("waiting to join room...")
done, pending = await asyncio.wait(
[
self.room_future,
self.stop_event.wait(),
],
return_when=asyncio.FIRST_COMPLETED
)
if self.room_future not in done:
self.room_future.cancel()
return
for fut in pending:
fut.cancel()
await self.stop_event.wait()
if __name__ == "__main__":
exec_example(MucLogger())
examples/muc_moderate_message.py 0000664 0000000 0000000 00000010076 14160146213 0017420 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: muc_logger.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
# .
#
########################################################################
import asyncio
import configparser
import locale
from datetime import datetime
import aioxmpp.muc
import aioxmpp.structs
from framework import Example, exec_example
class Moderate(aioxmpp.xso.XSO):
TAG = ("urn:xmpp:message-moderate:0", "moderate")
retract_flag = aioxmpp.xso.ChildFlag(("urn:xmpp:message-retract:0", "retract"))
reason = aioxmpp.xso.ChildText(("urn:xmpp:message-moderate:0", "reason"))
@aioxmpp.IQ.as_payload_class
class ApplyTo(aioxmpp.xso.XSO):
TAG = ("urn:xmpp:fasten:0", "apply-to")
id_ = aioxmpp.xso.Attr("id")
payload = aioxmpp.xso.Child([Moderate])
class Moderator(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"--muc",
type=jid,
default=None,
help="JID of the muc to join"
)
self.argparse.add_argument(
"--nick",
default=None,
help="Nick name to use"
)
self.argparse.add_argument(
"msgid",
)
self.argparse.add_argument(
"reason",
)
def configure(self):
super().configure()
self.muc_jid = self.args.muc
if self.muc_jid is None:
try:
self.muc_jid = aioxmpp.JID.fromstr(
self.config.get("muc_logger", "muc_jid")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.muc_jid = aioxmpp.JID.fromstr(
input("MUC JID> ")
)
self.muc_nick = self.args.nick
if self.muc_nick is None:
try:
self.muc_nick = self.config.get("muc_logger", "nick")
except (configparser.NoSectionError,
configparser.NoOptionError):
self.muc_nick = input("Nickname> ")
def make_simple_client(self):
client = super().make_simple_client()
muc = client.summon(aioxmpp.MUCClient)
room, self.room_future = muc.join(
self.muc_jid,
self.muc_nick
)
return client
async def run_example(self):
self.stop_event = self.make_sigint_event()
await super().run_example()
async def run_simple_example(self):
print("waiting to join room...")
done, pending = await asyncio.wait(
[
self.room_future,
asyncio.create_task(self.stop_event.wait()),
],
return_when=asyncio.FIRST_COMPLETED
)
if self.room_future not in done:
self.room_future.cancel()
return
for fut in pending:
fut.cancel()
payload = ApplyTo()
payload.id_ = self.args.msgid
payload.payload = Moderate()
payload.payload.reason = self.args.reason
payload.payload.retract_flag = True
await self.client.send(aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
to=self.muc_jid,
payload=payload,
))
if __name__ == "__main__":
exec_example(Moderator())
examples/ping.py 0000664 0000000 0000000 00000003373 14160146213 0014207 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: entity_info.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
# .
#
########################################################################
import asyncio
import itertools
import aioxmpp.disco
import aioxmpp.forms
from framework import Example, exec_example
class Ping(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"target_entity",
default=None,
nargs="*",
type=jid,
help="Entities to ping",
)
async def run_simple_example(self):
tasks = [
asyncio.wait_for(aioxmpp.ping.ping(self.client, target), timeout=60)
for target in self.args.target_entity
]
for addr, result in zip(self.args.target_entity, await asyncio.gather(*tasks, return_exceptions=True)):
print(addr, result)
if __name__ == "__main__":
exec_example(Ping())
examples/presence_info.py 0000664 0000000 0000000 00000007034 14160146213 0016067 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: presence_info.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
# .
#
########################################################################
import asyncio
import itertools
import pathlib
import aioxmpp.disco
import aioxmpp.entitycaps
import aioxmpp.presence
from framework import Example, exec_example
class PresenceInfo(Example):
def prepare_argparse(self):
super().prepare_argparse()
self.argparse.add_argument(
"--system-capsdb",
type=pathlib.Path,
default=None,
metavar="DIR",
help="Path to the capsdb",
)
self.argparse.add_argument(
"--user-capsdb",
metavar="DIR",
type=pathlib.Path,
default=pathlib.Path().cwd() / "user_hashes",
help="Path to the user capsdb (defaults to user_hashes/)",
)
self.argparse.epilog = """
Point --system-capsdb to a directory containing the capsdb
() to speed up fetching of
capabilities of peers.
"""
async def _show_info(self, full_jid):
info = await self.disco.query_info(full_jid)
print("{}:".format(full_jid))
print(" features:")
for feature in info.features:
print(" {!r}".format(feature))
print(" identities:")
identities = list(info.identities)
def identity_key(identity):
return identity.category, identity.type_
identities.sort(key=identity_key)
for (category, type_), identities in (
itertools.groupby(info.identities, identity_key)):
print(" category={!r} type={!r}".format(category, type_))
subidentities = list(identities)
subidentities.sort(key=lambda ident: ident.lang)
for identity in subidentities:
print(" [{}] {!r}".format(identity.lang, identity.name))
def _on_available(self, full_jid, stanza):
asyncio.ensure_future(self._show_info(full_jid))
def make_simple_client(self):
client = super().make_simple_client()
self.disco = client.summon(aioxmpp.DiscoClient)
self.caps = client.summon(aioxmpp.EntityCapsService)
if self.args.system_capsdb:
self.caps.cache.set_system_db_path(self.args.system_capsdb)
self.caps.cache.set_user_db_path(self.args.user_capsdb)
self.presence = client.summon(aioxmpp.PresenceClient)
self.presence.on_available.connect(
self._on_available
)
return client
async def run_simple_example(self):
for i in range(5, 0, -1):
print("going to wait {} more seconds for further "
"presence".format(i))
await asyncio.sleep(1)
if __name__ == "__main__":
exec_example(PresenceInfo())
examples/pubsub_items.py 0000664 0000000 0000000 00000004450 14160146213 0015750 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: pubsub_items.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
# .
#
########################################################################
import asyncio
import itertools
import aioxmpp.pubsub
import lxml.etree
from framework import Example, exec_example
class PubSubItems(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"target_entity",
type=jid
)
self.argparse.add_argument(
"target_node",
default=None,
nargs="?",
)
async def run_simple_example(self):
pubsub = self.client.summon(aioxmpp.PubSubClient)
try:
if self.args.target_node is None:
items = await pubsub.get_nodes(
self.args.target_entity
)
else:
items = await pubsub.get_items(
self.args.target_entity,
node=self.args.target_node,
)
except Exception as exc:
print("could not get info: ")
print("{}: {}".format(type(exc).__name__, exc))
raise
print("items:")
for item in items.payload.items:
if item.registered_payload is not None:
print(item.registered_payload)
else:
print(lxml.etree.tostring(item.unregistered_payload))
if __name__ == "__main__":
exec_example(PubSubItems())
examples/pubsub_watch.py 0000664 0000000 0000000 00000005041 14160146213 0015732 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: pubsub_watch.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
# .
#
########################################################################
import asyncio
import itertools
import aioxmpp
import aioxmpp.pubsub
import aioxmpp.xml
import lxml.etree as etree
from framework import Example, exec_example
class PubSubWatch(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"target_entity",
type=jid
)
self.argparse.add_argument(
"target_node",
default=None,
nargs="?",
)
async def run_example(self):
self.stop_event = self.make_sigint_event()
await super().run_example()
def _on_item_published(self, jid, node, item, *, message=None):
print("PUBLISHED: {}".format(item.id_), etree.tostring(item.unregistered_payload))
def _on_item_retracted(self, jid, node, id_, *, message=None):
print("RETRACTED: {}".format(id_))
async def run_simple_example(self):
pubsub = self.client.summon(aioxmpp.PubSubClient)
pubsub.on_item_published.connect(self._on_item_published)
pubsub.on_item_retracted.connect(self._on_item_retracted)
subid = (await pubsub.subscribe(
self.args.target_entity,
node=self.args.target_node,
)).payload.subid
print("SUBSCRIBED: subid={!r}".format(subid))
try:
await self.stop_event.wait()
finally:
await pubsub.unsubscribe(
self.args.target_entity,
node=self.args.target_node,
subid=subid,
)
if __name__ == "__main__":
exec_example(PubSubWatch())
examples/pushbot.py 0000664 0000000 0000000 00000016417 14160146213 0014741 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
########################################################################
# File name: pushbot.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
# .
#
########################################################################
import asyncio
import json
import logging
import pathlib
import socket
import signal
import toml
import aioxmpp
class MessageProtocol(asyncio.DatagramProtocol):
def __init__(self, queue):
super().__init__()
self.logger = logging.getLogger(type(self).__name__)
self.queue = queue
def datagram_received(self, data, addr):
try:
parsed = json.loads(data.decode("utf-8"))
except Exception:
self.logger.error("failed to parse client message %r",
data,
exc_info=True)
return
try:
self.queue.put_nowait(parsed)
except asyncio.QueueFull:
self.logger.error("input queue full! dropped message %r",
parsed)
async def process_item(item, rooms, logger):
target_rooms = rooms.keys()
tokens = []
for room_address in target_rooms:
try:
room_info = rooms[room_address]
except KeyError:
continue
body_parts = []
if room_info["head_format"]:
body_parts.append(
room_info["head_format"].format(
nitems=len(item["items"]),
root_item=item,
)
)
for sub_item in item["items"]:
required_fields = room_info["required_fields"]
if required_fields:
item_fields = set(sub_item.keys()) & required_fields
if len(item_fields) < len(required_fields):
continue
format_ = room_info["format"]
if format_:
body = format_.format(**sub_item)
else:
body = repr(item)
body_parts.append(body)
msg = aioxmpp.Message(
type_=aioxmpp.MessageType.GROUPCHAT,
)
msg.body[None] = "\n".join(body_parts)
tokens.append(asyncio.ensure_future(
room_info["room"].send_message(msg)
))
if not tokens:
logger.warning("item %r generated no message!", item)
return
await asyncio.wait(tokens, return_when=asyncio.ALL_COMPLETED)
async def process_queue(queue, rooms):
logger = logging.getLogger("processor")
while True:
item = await queue.get()
try:
await process_item(item, rooms, logger)
except Exception:
logger.error("failed to process item!", exc_info=True)
continue
async def amain(loop, xmpp_cfg, unix_cfg, mucs):
message_queue = asyncio.Queue(maxsize=16)
message_handler = MessageProtocol(message_queue)
sigint_received = asyncio.Event()
sigint_future = asyncio.ensure_future(sigint_received.wait())
loop.add_signal_handler(signal.SIGINT, sigint_received.set)
loop.add_signal_handler(signal.SIGTERM, sigint_received.set)
socket_path, = unix_cfg
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM, 0)
sock.bind(str(socket_path))
unix_transport, _ = await loop.create_datagram_endpoint(
lambda: message_handler,
sock=sock,
)
address, password = xmpp_cfg
xmpp_client = aioxmpp.Client(
address,
aioxmpp.make_security_layer(password)
)
muc_client = xmpp_client.summon(aioxmpp.MUCClient)
try:
async with xmpp_client.connected() as stream:
rooms = {}
for muc_info in mucs:
room, fut = muc_client.join(
muc_info["address"],
muc_info["nickname"],
autorejoin=True,
)
await fut
muc_info["room"] = room
rooms[muc_info["address"]] = muc_info
processor = asyncio.ensure_future(process_queue(
message_queue,
rooms
))
done, pending = await asyncio.wait(
[
processor,
sigint_future,
],
return_when=asyncio.FIRST_COMPLETED,
)
if sigint_future in done:
if not processor.done():
processor.cancel()
try:
await processor
except asyncio.CancelledError:
pass
return
if processor in done:
processor.result()
raise RuntimeError("processor exited early!")
finally:
if not sigint_future.done():
sigint_future.cancel()
unix_transport.close()
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
"-c", "--config",
type=pathlib.Path,
default=pathlib.Path.cwd() / "config.toml",
help="Path to config file (default: ./config.toml)"
)
parser.add_argument(
"-v", "--verbose",
default=0,
dest="verbosity",
action="count",
help="Increase verbosity (up to -vvv)"
)
args = parser.parse_args()
logging.basicConfig(
level={
0: logging.ERROR,
1: logging.WARNING,
2: logging.INFO,
}.get(args.verbosity, logging.DEBUG)
)
with args.config.open("r") as f:
config = toml.load(f)
address = aioxmpp.JID.fromstr(config["xmpp"]["account"])
password = config["xmpp"]["password"]
socket_path = pathlib.Path(config["unix"]["path"])
mucs = []
for muc_cfg in config["xmpp"]["muc"]:
mucs.append(
{
"address": aioxmpp.JID.fromstr(muc_cfg["address"]),
"nickname": muc_cfg.get("nickname", address.localpart),
"format": muc_cfg.get("format"),
"required_fields": frozenset(muc_cfg.get("required_fields", [])),
"head_format": muc_cfg.get("head_format"),
}
)
if socket_path.exists():
if not socket_path.is_socket():
raise RuntimeError("{} exists and is not a socket!".format(
socket_path,
))
# FIXME: do not unlink the socket if it’s still live; abort instead.
socket_path.unlink()
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(amain(
loop,
(address, password),
(socket_path, ),
mucs,
))
finally:
loop.close()
if __name__ == "__main__":
main()
examples/query_versions.py 0000664 0000000 0000000 00000007675 14160146213 0016360 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: query_versions.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
# .
#
########################################################################
import argparse
import asyncio
import itertools
import sys
import aioxmpp
import aioxmpp.disco
import aioxmpp.errors
import aioxmpp.version
import aioxmpp.xso
from framework import Example, exec_example
if not hasattr(asyncio, "ensure_future"):
asyncio.ensure_future = getattr(asyncio, "async")
class SoftwareVersions(Example):
def prepare_argparse(self):
super().prepare_argparse()
self.argparse.add_argument(
"--timeout",
type=float,
help="Maximum time (in seconds) to wait for a response "
"(default: 20s)",
default=20,
)
group = self.argparse.add_mutually_exclusive_group(required=True)
group.add_argument(
"-f", "--from-file",
type=argparse.FileType("r"),
dest="from_file",
help="Read the JIDs from a file, one line per JID.",
default=None,
)
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
group.add_argument(
"-t", "--targets",
nargs="+",
dest="jids",
help="The JIDs to query as command-line arguments",
default=[],
type=jid,
)
def configure(self):
super().configure()
self.jids = list(self.args.jids)
if self.args.from_file:
with self.args.from_file as f:
for line in f:
self.jids.append(aioxmpp.JID.fromstr(line[:-1]))
self.timeout = self.args.timeout
async def run_example(self):
self.stop_event = self.make_sigint_event()
await super().run_example()
def format_version(self, version_xso):
return "name={!r} version={!r} os={!r}".format(
version_xso.name,
version_xso.version,
version_xso.os,
)
async def run_simple_example(self):
tasks = []
stream = self.client.stream
for jid in self.jids:
tasks.append(
asyncio.ensure_future(aioxmpp.version.query_version(
stream,
jid,
))
)
gather_task = asyncio.ensure_future(
asyncio.wait(
tasks,
return_when=asyncio.ALL_COMPLETED,
)
)
cancel_fut = asyncio.ensure_future(self.stop_event.wait())
await asyncio.wait(
[
gather_task,
cancel_fut,
],
return_when=asyncio.FIRST_COMPLETED,
)
for target, fut in zip(self.jids, tasks):
if not fut.done():
fut.cancel()
continue
if fut.exception():
print("{} failed: {}".format(target, fut.exception()),
file=sys.stderr)
continue
print("{}: {}".format(target, self.format_version(fut.result())))
if not cancel_fut.done():
cancel_fut.cancel()
if __name__ == "__main__":
exec_example(SoftwareVersions())
examples/quickstart_echo_bot.py 0000664 0000000 0000000 00000004064 14160146213 0017304 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: quickstart_echo_bot.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
# .
#
########################################################################
import asyncio
import getpass
try:
import readline # NOQA
except ImportError:
pass
import aioxmpp
async def main(local_jid, password):
client = aioxmpp.PresenceManagedClient(
local_jid,
aioxmpp.make_security_layer(password)
)
def message_received(msg):
if not msg.body:
# do not reflect anything without a body
return
# we could also use reply = msg.make_reply() instead
reply = aioxmpp.Message(
type_=msg.type_,
to=msg.from_,
)
# make_reply() would not set the body though
reply.body.update(msg.body)
client.enqueue(reply)
message_dispatcher = client.summon(
aioxmpp.dispatcher.SimpleMessageDispatcher
)
message_dispatcher.register_callback(
aioxmpp.MessageType.CHAT,
None,
message_received,
)
async with client.connected():
while True:
await asyncio.sleep(1)
if __name__ == "__main__":
local_jid = aioxmpp.JID.fromstr(input("local JID> "))
password = getpass.getpass()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(local_jid, password))
loop.close()
examples/quickstart_serve_software_version.py 0000664 0000000 0000000 00000003653 14160146213 0022330 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: quickstart_serve_software_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
# .
#
########################################################################
import asyncio
import getpass
try:
import readline # NOQA
except ImportError:
pass
import aioxmpp
import aioxmpp.xso as xso
from aioxmpp.version.xso import Query
async def handler(iq):
print("software version request from {!r}".format(iq))
result = Query()
result.name = "aioxmpp Quick Start Pro"
result.version = "23.42"
result.os = "MFHBμKOS (My Fancy HomeBrew Micro Kernel Operating System)"
return result
async def main(local_jid, password):
client = aioxmpp.PresenceManagedClient(
local_jid,
aioxmpp.make_security_layer(password)
)
client.stream.register_iq_request_coro(
"get",
Query,
handler,
)
async with client.connected():
await asyncio.sleep(30)
if __name__ == "__main__":
local_jid = aioxmpp.JID.fromstr(input("local JID> "))
password = getpass.getpass()
import logging
logging.basicConfig(level=logging.DEBUG)
loop = asyncio.get_event_loop()
loop.run_until_complete(main(local_jid, password))
loop.close()
examples/register.py 0000664 0000000 0000000 00000002726 14160146213 0015077 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: register.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
# .
#
########################################################################
import asyncio
import getpass
try:
import readline
except ImportError:
pass
import aioxmpp
import aioxmpp.ibr as ibr
async def register(jid, password):
metadata = aioxmpp.make_security_layer(None)
_, stream, features = await aioxmpp.node.connect_xmlstream(jid, metadata)
query = ibr.Query(jid.localpart, password)
await ibr.register(stream, query)
print("Registered")
if __name__ == "__main__":
local_jid = aioxmpp.JID.fromstr(input("local JID> "))
password = getpass.getpass()
loop = asyncio.get_event_loop()
loop.run_until_complete(register(local_jid, password))
examples/retrieve_avatar.py 0000664 0000000 0000000 00000005504 14160146213 0016433 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: retrieve_avatar.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
# .
#
########################################################################
import asyncio
import configparser
import aioxmpp
import aioxmpp.avatar
from framework import Example, exec_example
class Avatar(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"output_file",
help="the file the retrieved avatar image will be written to."
)
self.argparse.add_argument(
"--remote-jid",
type=jid,
help="the jid of which to retrieve the avatar"
)
def configure(self):
super().configure()
self.output_file = self.args.output_file
self.remote_jid = self.args.remote_jid
if self.remote_jid is None:
try:
self.remote_jid = aioxmpp.JID.fromstr(
self.config.get("avatar", "remote_jid")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.remote_jid = aioxmpp.JID.fromstr(
input("Remote JID> ")
)
def make_simple_client(self):
client = super().make_simple_client()
self.avatar = client.summon(aioxmpp.avatar.AvatarService)
return client
async def run_simple_example(self):
metadata = await self.avatar.get_avatar_metadata(
self.remote_jid,
disable_pep=True
)
for metadatum in metadata:
if metadatum.can_get_image_bytes_via_xmpp:
image = await metadatum.get_image_bytes()
with open(self.output_file, "wb") as avatar_image:
avatar_image.write(image)
return
print("retrieving avatar failed: no avatar available via xmpp")
async def run_example(self):
await super().run_example()
if __name__ == "__main__":
exec_example(Avatar())
examples/roster.py 0000664 0000000 0000000 00000004637 14160146213 0014574 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: roster.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
# .
#
########################################################################
import asyncio
import aioxmpp.roster
from framework import Example, exec_example
class Roster(Example):
def _print_item(self, item):
print(" entry {}:".format(item.jid))
print(" name={!r}".format(item.name))
print(" subscription={!r}".format(item.subscription))
print(" ask={!r}".format(item.ask))
print(" approved={!r}".format(item.approved))
def _on_initial_roster(self):
for group, items in self.roster.groups.items():
print("group {}:".format(group))
for item in items:
self._print_item(item)
print("ungrouped items:")
for item in self.roster.items.values():
if not item.groups:
self._print_item(item)
self.done_event.set()
def make_simple_client(self):
client = super().make_simple_client()
self.roster = client.summon(aioxmpp.RosterClient)
self.roster.on_initial_roster_received.connect(
self._on_initial_roster,
)
self.done_event = asyncio.Event()
return client
async def run_simple_example(self):
done, pending = await asyncio.wait(
[
self.sigint_event.wait(),
self.done_event.wait()
],
return_when=asyncio.FIRST_COMPLETED,
)
for fut in pending:
fut.cancel()
async def run_example(self):
self.sigint_event = self.make_sigint_event()
await super().run_example()
if __name__ == "__main__":
exec_example(Roster())
examples/roster_watch.py 0000664 0000000 0000000 00000005267 14160146213 0015762 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: roster.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
# .
#
########################################################################
import asyncio
import functools
import aioxmpp.roster
from framework import Example, exec_example
class Roster(Example):
def _print_item(self, item):
print(" entry {}:".format(item.jid))
print(" name={!r}".format(item.name))
print(" subscription={!r}".format(item.subscription))
print(" ask={!r}".format(item.ask))
print(" approved={!r}".format(item.approved))
def _on_initial_roster(self):
for group, items in self.roster.groups.items():
print("group {}:".format(group))
for item in items:
self._print_item(item)
print("ungrouped items:")
for item in self.roster.items.values():
if not item.groups:
self._print_item(item)
print("--- END OF INITIAL ROSTER ---")
def _on_entry_changed(self, what, item):
print(what.upper(), "item:")
self._print_item(item)
def make_simple_client(self):
client = super().make_simple_client()
self.roster = client.summon(aioxmpp.RosterClient)
self.roster.on_initial_roster_received.connect(
self._on_initial_roster,
)
self.roster.on_entry_added.connect(
functools.partial(self._on_entry_changed, "added")
)
self.roster.on_entry_name_changed.connect(
functools.partial(self._on_entry_changed, "name changed")
)
self.roster.on_entry_subscription_state_changed.connect(
functools.partial(self._on_entry_changed, "subscription changed")
)
return client
async def run_simple_example(self):
await self.sigint_event.wait()
async def run_example(self):
self.sigint_event = self.make_sigint_event()
await super().run_example()
if __name__ == "__main__":
exec_example(Roster())
examples/send_message.py 0000664 0000000 0000000 00000003535 14160146213 0015707 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: send_message.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
# .
#
########################################################################
import asyncio
import aioxmpp
from framework import Example, exec_example
class SendMessage(Example):
def prepare_argparse(self):
super().prepare_argparse()
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"recipient",
type=jid,
help="Recipient JID"
)
self.argparse.add_argument(
"message",
nargs="?",
default="Hello World!",
help="Message to send (default: Hello World!)",
)
async def run_simple_example(self):
# compose a message
msg = aioxmpp.stanza.Message(
to=self.args.recipient,
type_=aioxmpp.MessageType.CHAT,
)
# [None] is for "no XML language tag"
msg.body[None] = self.args.message
print("sending message ...")
await self.client.send(msg)
print("message sent!")
if __name__ == "__main__":
exec_example(SendMessage())
examples/send_raw.py 0000664 0000000 0000000 00000003505 14160146213 0015051 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: send_raw.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
# .
#
########################################################################
import asyncio
import aioxmpp
import lxml.etree as etree
from framework import Example, exec_example
aioxmpp.Message.raw = aioxmpp.xso.Collector()
class SendMessage(Example):
def prepare_argparse(self):
super().prepare_argparse()
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"recipient",
type=jid,
help="Recipient JID"
)
self.argparse.add_argument(
"xml",
type=etree.fromstring,
help="XML to send as message",
)
async def run_simple_example(self):
# compose a message
msg = aioxmpp.stanza.Message(
to=self.args.recipient,
type_=aioxmpp.MessageType.CHAT,
)
msg.raw.append(self.args.xml)
print("sending message ...")
await self.client.send(msg)
print("message sent!")
if __name__ == "__main__":
exec_example(SendMessage())
examples/sendxmpp.py 0000664 0000000 0000000 00000001623 14160146213 0015104 0 ustar 00root root 0000000 0000000 import argparse
import asyncio
import os
import aioxmpp
async def main(from_jid, to_jid, password, message):
client = aioxmpp.PresenceManagedClient(
aioxmpp.JID.fromstr(from_jid),
aioxmpp.make_security_layer(password),
)
async with client.connected() as stream:
msg = aioxmpp.Message(
to=aioxmpp.JID.fromstr(to_jid),
type_=aioxmpp.MessageType.CHAT,
)
msg.body[None] = message
await stream.send(msg)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("from_")
parser.add_argument("to")
parser.add_argument("message")
args = parser.parse_args()
password = os.environ["SENDXMPP_PASSWORD"]
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(main(args.from_, args.to, password, args.message))
finally:
loop.close()
examples/server_info.py 0000664 0000000 0000000 00000004227 14160146213 0015572 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: server_info.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
# .
#
########################################################################
import asyncio
import itertools
import aioxmpp.disco
from framework import Example, exec_example
class ServerInfo(Example):
async def run_simple_example(self):
disco = self.client.summon(aioxmpp.DiscoClient)
try:
info = await disco.query_info(
self.g_jid.replace(resource=None, localpart=None),
timeout=10
)
except Exception as exc:
print("could not get info: ")
print("{}: {}".format(type(exc).__name__, exc))
raise
print("features:")
for feature in info.features:
print(" {!r}".format(feature))
print("identities:")
identities = list(info.identities)
def identity_key(ident):
return (ident.category, ident.type_)
identities.sort(key=identity_key)
for (category, type_), identities in (
itertools.groupby(info.identities, identity_key)):
print(" category={!r} type={!r}".format(category, type_))
subidentities = list(identities)
subidentities.sort(key=lambda ident: ident.lang)
for identity in subidentities:
print(" [{}] {!r}".format(identity.lang, identity.name))
if __name__ == "__main__":
exec_example(ServerInfo())
examples/set_avatar.py 0000664 0000000 0000000 00000004642 14160146213 0015403 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: set_avatar.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
# .
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.avatar
from framework import Example, exec_example
class Avatar(Example):
def prepare_argparse(self):
super().prepare_argparse()
group = self.argparse.add_mutually_exclusive_group(required=True)
group.add_argument(
"--set-avatar", nargs=1, metavar="AVATAR_FILE",
help="set the avatar to content of the supplied PNG file."
)
group.add_argument(
"--wipe-avatar",
action="store_true",
default=False,
help="set the avatar to no avatar."
)
def configure(self):
super().configure()
if self.args.set_avatar:
self.avatar_file, = self.args.set_avatar
else:
self.avatar_file = None
self.wipe_avatar = self.args.wipe_avatar
def make_simple_client(self):
client = super().make_simple_client()
self.avatar = client.summon(aioxmpp.avatar.AvatarService)
return client
async def run_simple_example(self):
if self.avatar_file is not None:
with open(self.avatar_file, "rb") as f:
image_data = f.read()
avatar_set = aioxmpp.avatar.AvatarSet()
avatar_set.add_avatar_image("image/png", image_bytes=image_data)
await self.avatar.publish_avatar_set(avatar_set)
elif self.wipe_avatar:
await self.avatar.disable_avatar()
async def run_example(self):
await super().run_example()
if __name__ == "__main__":
exec_example(Avatar())
examples/set_muc_avatar.py 0000664 0000000 0000000 00000005505 14160146213 0016246 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: set_avatar.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
# .
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.avatar
from framework import Example, exec_example
class Avatar(Example):
def prepare_argparse(self):
super().prepare_argparse()
self.argparse.add_argument(
"muc_jid",
type=aioxmpp.JID.fromstr,
)
self.argparse.add_argument(
"--mime-type",
default="image/png",
)
group = self.argparse.add_mutually_exclusive_group(required=True)
group.add_argument(
"--set-avatar", nargs=1, metavar="AVATAR_FILE",
help="set the avatar to content of the supplied file."
)
group.add_argument(
"--wipe-avatar",
action="store_true",
default=False,
help="set the avatar to no avatar."
)
def configure(self):
super().configure()
if self.args.set_avatar:
self.avatar_file, = self.args.set_avatar
else:
self.avatar_file = None
self.wipe_avatar = self.args.wipe_avatar
self.target_jid = self.args.muc_jid
self.mime_type = self.args.mime_type
def make_simple_client(self):
client = super().make_simple_client()
self.vcard = client.summon(aioxmpp.vcard.VCardService)
return client
async def run_simple_example(self):
vcard = await self.vcard.get_vcard(jid=self.target_jid)
vcard.clear_photo_data()
if self.avatar_file is not None:
with open(self.avatar_file, "rb") as f:
image_data = f.read()
vcard = await self.vcard.get_vcard(jid=self.target_jid)
vcard.set_photo_data(self.mime_type, image_data)
await self.vcard.set_vcard(vcard, jid=self.target_jid)
elif self.wipe_avatar:
await self.vcard.set_vcard(vcard, jid=self.target_jid)
async def run_example(self):
await super().run_example()
if __name__ == "__main__":
exec_example(Avatar())
examples/set_muc_config.py 0000664 0000000 0000000 00000011176 14160146213 0016236 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: set_muc_config.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
# .
#
########################################################################
import asyncio
import configparser
import aioxmpp.muc
import aioxmpp.muc.xso
from framework import Example, exec_example
class ServerInfo(Example):
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
self.argparse.add_argument(
"--muc",
type=jid,
default=None,
help="JID of the muc to query"
)
self.argparse.set_defaults(
moderated=None,
persistent=None,
membersonly=None,
)
mutex = self.argparse.add_mutually_exclusive_group()
mutex.add_argument(
"--set-moderated",
dest="moderated",
action="store_true",
help="Set the room to be moderated",
)
mutex.add_argument(
"--clear-moderated",
dest="moderated",
action="store_false",
help="Set the room to be unmoderated",
)
mutex = self.argparse.add_mutually_exclusive_group()
mutex.add_argument(
"--set-persistent",
dest="persistent",
action="store_true",
help="Set the room to be persistent",
)
mutex.add_argument(
"--clear-persistent",
dest="persistent",
action="store_false",
help="Set the room to be not persistent",
)
mutex = self.argparse.add_mutually_exclusive_group()
mutex.add_argument(
"--set-members-only",
dest="membersonly",
action="store_true",
help="Set the room to be members only",
)
mutex.add_argument(
"--clear-members-only",
dest="membersonly",
action="store_false",
help="Set the room to be joinable by everyone",
)
self.argparse.add_argument(
"--description",
dest="description",
default=None,
help="Change the natural-language room description"
)
self.argparse.add_argument(
"--name",
dest="name",
default=None,
help="Change the natural-language room name"
)
def configure(self):
super().configure()
self.muc_jid = self.args.muc
if self.muc_jid is None:
try:
self.muc_jid = aioxmpp.JID.fromstr(
self.config.get("muc_config", "muc_jid")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
self.muc_jid = aioxmpp.JID.fromstr(
input("MUC JID> ")
)
def make_simple_client(self):
client = super().make_simple_client()
client.summon(aioxmpp.MUCClient)
return client
async def run_simple_example(self):
muc = self.client.summon(aioxmpp.MUCClient)
config = await muc.get_room_config(
self.muc_jid
)
form = aioxmpp.muc.xso.ConfigurationForm.from_xso(config)
if self.args.membersonly is not None:
form.membersonly.value = self.args.membersonly
if self.args.persistent is not None:
form.persistent.value = self.args.persistent
if self.args.moderated is not None:
form.moderatedroom.value = self.args.moderated
if self.args.description is not None:
form.roomdesc.value = self.args.description
if self.args.name is not None:
form.roomname.value = self.args.name
await muc.set_room_config(
self.muc_jid,
form.render_reply()
)
if __name__ == "__main__":
exec_example(ServerInfo())
examples/upload.py 0000664 0000000 0000000 00000015376 14160146213 0014544 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: upload.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
# .
#
########################################################################
import argparse
import asyncio
import configparser
import os
import pathlib
import re
import subprocess
import sys
import aiohttp
import aioxmpp
import aioxmpp.httpupload
from aioxmpp.utils import namespaces
from framework import Example, exec_example
@aiohttp.streamer
def file_sender(writer, file_, size, show_progress):
try:
pos = file_.tell()
except (OSError, AttributeError):
pos = 0
while True:
data = file_.read(4096)
if not data:
return
pos += len(data)
if show_progress:
print(
"\r{:>3.0f}%".format((pos / size) * 100),
flush=True,
end="",
)
yield from writer.write(data)
class Upload(Example):
VALID_MIME_RE = re.compile(r"^\w+/\w+$")
DEFAULT_MIME_TYPE = "application/octet-stream"
def prepare_argparse(self):
super().prepare_argparse()
# this gives a nicer name in argparse errors
def jid(s):
return aioxmpp.JID.fromstr(s)
mutex = self.argparse.add_mutually_exclusive_group()
mutex.add_argument(
"-s", "--service",
default=None,
type=jid,
help="The HTTP Upload service to use. Omit to auto-discover."
)
mutex.add_argument(
"--service-discover",
dest="service",
action="store_const",
const=False,
help="Force auto-discovery, even if a service is configured."
)
self.argparse.add_argument(
"-t", "--mime-type", "--content-type",
default=None,
help="Content / MIME type of the file "
"(will attempt to auto-detect if omitted)"
)
mutex = self.argparse.add_mutually_exclusive_group()
mutex.add_argument(
"--quiet",
dest="progress",
action="store_false",
default=os.isatty(sys.stdout.fileno()),
help="Do not print progress",
)
mutex.add_argument(
"--progress",
dest="progress",
action="store_true",
help="Print progress",
)
self.argparse.add_argument(
"file",
default=None,
type=argparse.FileType("rb"),
help="File to upload"
)
def configure(self):
super().configure()
self.service_addr = self.args.service
if self.service_addr is None:
try:
self.service_addr = aioxmpp.JID.fromstr(
self.config.get("upload", "service_address")
)
except (configparser.NoSectionError,
configparser.NoOptionError):
pass
self.file = self.args.file
self.file_name = pathlib.Path(self.file.name).name
self.file_size = os.fstat(self.file.fileno()).st_size
self.file_type = self.args.mime_type
self.show_progress = self.args.progress
if not self.file_type:
try:
self.file_type = subprocess.check_output([
"xdg-mime", "query", "filetype",
self.file.name,
]).decode().strip()
except subprocess.CalledProcessError:
self.file_type = self.DEFAULT_MIME_TYPE
print("warning: failed to determine mime type, using {}".format(
self.file_type,
))
async def _check_for_upload_service(self, disco, jid):
info = await disco.query_info(jid)
if namespaces.xep0363_http_upload in info.features:
return jid
async def upload(self, url, headers):
headers["Content-Type"] = self.file_type
headers["Content-Length"] = str(self.file_size)
headers["User-Agent"] = "aioxmpp/{}".format(aioxmpp.__version__)
async with aiohttp.ClientSession() as session:
async with session.put(
url,
data=file_sender(file_=self.file,
size=self.file_size,
show_progress=self.show_progress),
headers=headers) as response:
if self.show_progress:
print("\r", end="")
if response.status not in (200, 201):
print(
"error: upload failed: {}".format(response.reason),
file=sys.stderr,
)
return False
return True
async def run_simple_example(self):
if not self.service_addr:
disco = self.client.summon(aioxmpp.DiscoClient)
items = await disco.query_items(
self.client.local_jid.replace(localpart=None, resource=None),
timeout=10
)
lookups = []
for item in items.items:
if item.node:
continue
lookups.append(self._check_for_upload_service(disco, item.jid))
jids = list(filter(
None,
await asyncio.gather(*lookups)
))
if not jids:
print("error: failed to auto-discover upload service",
file=sys.stderr)
return
self.service_addr = jids[0]
print("using {}".format(self.service_addr), file=sys.stderr)
slot = await self.client.send(
aioxmpp.IQ(
to=self.service_addr,
type_=aioxmpp.IQType.GET,
payload=aioxmpp.httpupload.Request(
self.file_name,
self.file_size,
self.file_type,
)
)
)
if not await self.upload(slot.put.url, slot.put.headers):
return
print(slot.get.url)
if __name__ == "__main__":
exec_example(Upload())
examples/xmpp_bridge.py 0000664 0000000 0000000 00000012401 14160146213 0015542 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
########################################################################
# File name: xmpp_bridge.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
# .
#
########################################################################
import asyncio
import asyncio.streams
import os
import signal
import sys
import aioxmpp
async def stdout_writer():
"""
This is a bit complex, as stdout can be a pipe or a file.
If it is a file, we cannot use
:meth:`asycnio.BaseEventLoop.connect_write_pipe`.
"""
if sys.stdout.seekable():
# it’s a file
return sys.stdout.buffer.raw
if os.isatty(sys.stdin.fileno()):
# it’s a tty, use fd 0
fd_to_use = 0
else:
fd_to_use = 1
twrite, pwrite = await loop.connect_write_pipe(
asyncio.streams.FlowControlMixin,
os.fdopen(fd_to_use, "wb"),
)
swrite = asyncio.StreamWriter(
twrite,
pwrite,
None,
loop,
)
return swrite
async def main(local, password, peer,
strip_newlines, add_newlines):
loop = asyncio.get_event_loop()
swrite = await stdout_writer()
sread = asyncio.StreamReader()
tread, pread = await loop.connect_read_pipe(
lambda: asyncio.StreamReaderProtocol(sread),
sys.stdin,
)
client = aioxmpp.Client(
local,
aioxmpp.make_security_layer(
password,
)
)
sigint = asyncio.Event()
loop.add_signal_handler(signal.SIGINT, sigint.set)
loop.add_signal_handler(signal.SIGTERM, sigint.set)
def recv(message):
body = message.body.lookup(
[aioxmpp.structs.LanguageRange.fromstr("*")]
)
if add_newlines:
body += "\n"
swrite.write(body.encode("utf-8"))
client.stream.register_message_callback(
"chat",
peer,
recv
)
sigint_future = asyncio.ensure_future(sigint.wait())
read_future = asyncio.ensure_future(sread.readline())
try:
async with client.connected() as stream:
# we send directed presence to the peer
pres = aioxmpp.Presence(
type_=aioxmpp.PresenceType.AVAILABLE,
to=peer,
)
await stream.send(pres)
while True:
done, pending = await asyncio.wait(
[
sigint_future,
read_future,
],
return_when=asyncio.FIRST_COMPLETED,
)
if sigint_future in done:
break
if read_future in done:
line = read_future.result().decode()
if not line:
break
if strip_newlines:
line = line.rstrip()
msg = aioxmpp.Message(
type_="chat",
to=peer,
)
msg.body[None] = line
await stream.send(msg)
read_future = asyncio.ensure_future(
sread.readline()
)
finally:
sigint_future.cancel()
read_future.cancel()
def jid(s):
return aioxmpp.JID.fromstr(s)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="""
Send lines from stdin to the given peer and print messages received
from the peer to stdout.""",
epilog="""
The password must be set in the XMPP_BRIDGE_PASSWORD environment
variable."""
)
parser.add_argument(
"--no-strip-newlines",
dest="strip_newlines",
action="store_false",
default=True,
help="Disable stripping newlines from stdin"
)
parser.add_argument(
"--no-add-newlines",
dest="add_newlines",
action="store_false",
default=True,
help="Disable adding newlines to stdout"
)
parser.add_argument(
"local",
help="JID to bind to",
type=jid,
)
parser.add_argument(
"peer",
help="JID of the peer to send messages to",
type=jid,
)
args = parser.parse_args()
try:
password = os.environ["XMPP_BRIDGE_PASSWORD"]
except KeyError:
parser.print_help()
print("XMPP_BRIDGE_PASSWORD is not set", file=sys.stderr)
sys.exit(1)
loop = asyncio.get_event_loop()
loop.run_until_complete(main(
args.local, password, args.peer,
args.strip_newlines,
args.add_newlines,
))
loop.close()
setup.py 0000664 0000000 0000000 00000005757 14160146213 0012604 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
########################################################################
# File name: setup.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
# 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
# .
#
########################################################################
import os.path
import runpy
import sys
import setuptools
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(here, "README.rst"), encoding="utf-8") as f:
long_description = f.read()
version_mod = runpy.run_path("aioxmpp/_version.py")
lxml_constraint = "lxml~=4.0"
if sys.version_info < (3, 5):
lxml_constraint += ",<4.4"
sortedcollections_constraint = "sortedcollections~=2.1"
if sys.version_info < (3, 6):
sortedcollections_constraint = "sortedcollections~=1.0"
install_requires = [
'aiosasl>=0.3', # need 0.2+ for LGPLv3
'aioopenssl>=0.1',
'babel~=2.3',
'dnspython>=1.0,<3.0',
lxml_constraint,
'multidict<6,>=2.0',
sortedcollections_constraint,
'pyOpenSSL',
'pyasn1',
'pyasn1_modules',
'tzlocal>=1.2',
]
if tuple(map(int, setuptools.__version__.split(".")[:3])) < (6, 0, 0):
for i, item in enumerate(install_requires):
install_requires[i] = item.replace("~=", ">=")
if sys.version_info[:3] < (3, 5, 0):
install_requires.append("typing")
setup(
name="aioxmpp",
version=version_mod["__version__"].replace("-", ""),
description="Pure-python XMPP library for asyncio",
long_description=long_description,
url="https://github.com/horazont/aioxmpp",
author="Jonas Schäfer",
author_email="jonas@wielicki.name",
license="LGPLv3+",
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Operating System :: POSIX",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Topic :: Communications :: Chat",
"Topic :: Internet :: XMPP",
],
keywords="asyncio xmpp library",
install_requires=install_requires,
packages=find_packages(exclude=["tests*", "benchmarks*"])
)
tests/ 0000775 0000000 0000000 00000000000 14160146213 0012216 5 ustar 00root root 0000000 0000000 tests/__init__.py 0000664 0000000 0000000 00000002240 14160146213 0014325 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
from aioxmpp.e2etest import ( # NOQA
setup_package as e2etest_setup_package,
teardown_package,
)
import warnings
def setup_package():
e2etest_setup_package()
warnings.filterwarnings(
"error",
message=".+(Stream)?ErrorCondition",
category=DeprecationWarning,
)
tests/adhoc/ 0000775 0000000 0000000 00000000000 14160146213 0013274 5 ustar 00root root 0000000 0000000 tests/adhoc/__init__.py 0000664 0000000 0000000 00000001555 14160146213 0015413 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/adhoc/test_e2e.py 0000664 0000000 0000000 00000015205 14160146213 0015363 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_e2e.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
# .
#
########################################################################
import asyncio
import aioxmpp
import aioxmpp.adhoc
from aioxmpp.utils import namespaces
from aioxmpp.e2etest import (
require_feature,
blocking,
blocking_timed,
TestCase,
skip_with_quirk,
Quirk,
)
class TestAdHocClient(TestCase):
@require_feature(namespaces.xep0050_commands, multiple=True)
@blocking
async def setUp(self, commands_providers):
services = [aioxmpp.AdHocClient]
self.peers = commands_providers
self.client, = await asyncio.gather(
self.provisioner.get_connected_client(
services=services,
),
)
self.svc = self.client.summon(aioxmpp.AdHocClient)
async def _get_ping_peer(self):
for peer in self.peers:
for item in await self.svc.get_commands(peer):
if item.node == "ping":
return peer
self.assertTrue(
False,
"found no peer which offers ad-hoc-ping; consider setting "
"the #no-adhoc-ping quirk"
)
@blocking_timed
async def test_get_list(self):
items = []
for peer in self.peers:
items.extend(await self.svc.get_commands(
peer
))
self.assertTrue(items)
@skip_with_quirk(Quirk.NO_ADHOC_PING)
@blocking_timed
async def test_ping(self):
ping_peer = await self._get_ping_peer()
session = await self.svc.execute(ping_peer, "ping")
self.assertTrue(session.response.notes)
await session.close()
@skip_with_quirk(Quirk.NO_ADHOC_PING)
@blocking_timed
async def test_ping_with_async_cm(self):
ping_peer = await self._get_ping_peer()
session = await self.svc.execute(ping_peer, "ping")
async with session:
self.assertTrue(session.response.notes)
class TestAdHocServer(TestCase):
@blocking
async def setUp(self):
self.client, self.server = await asyncio.gather(
self.provisioner.get_connected_client(
services=[aioxmpp.adhoc.AdHocClient],
),
self.provisioner.get_connected_client(
services=[aioxmpp.adhoc.AdHocServer],
),
)
self.client_svc = self.server.summon(aioxmpp.adhoc.AdHocClient)
self.server_svc = self.server.summon(aioxmpp.adhoc.AdHocServer)
async def _trivial_handler(self, stanza):
return aioxmpp.adhoc.xso.Command(
"simple",
notes=[
aioxmpp.adhoc.xso.Note(
aioxmpp.adhoc.xso.NoteType.INFO,
"some info!"
)
]
)
@blocking_timed
async def test_advertises_command_support(self):
self.assertTrue(await self.client_svc.supports_commands(
self.server.local_jid,
))
@blocking_timed
async def test_respond_to_command_listing(self):
self.server_svc.register_stateless_command(
"simple",
{
aioxmpp.structs.LanguageTag.fromstr("en"): "Simple command",
aioxmpp.structs.LanguageTag.fromstr("de"): "Einfacher Befehl",
},
self._trivial_handler,
)
commands = await self.client_svc.get_commands(
self.server.local_jid
)
self.assertEqual(len(commands), 1)
self.assertEqual(
commands[0].node,
"simple",
)
self.assertEqual(
commands[0].name,
"Simple command",
)
@blocking_timed
async def test_respond_to_command_info_query(self):
self.server_svc.register_stateless_command(
"simple",
{
aioxmpp.structs.LanguageTag.fromstr("en"): "Simple command",
aioxmpp.structs.LanguageTag.fromstr("de"): "Einfacher Befehl",
},
self._trivial_handler,
features={"foo"},
)
info = await self.client_svc.get_command_info(
self.server.local_jid,
"simple",
)
self.assertSetEqual(
info.features,
{
namespaces.xep0050_commands,
namespaces.xep0030_info,
"foo",
}
)
self.assertCountEqual(
[
("automation", "command-node",
aioxmpp.structs.LanguageTag.fromstr("en"), "Simple command"),
("automation", "command-node",
aioxmpp.structs.LanguageTag.fromstr("de"), "Einfacher Befehl"),
],
[
(ident.category, ident.type_, ident.lang, ident.name)
for ident in info.identities
]
)
@blocking_timed
async def test_execute_simple_command(self):
self.server_svc.register_stateless_command(
"simple",
{
aioxmpp.structs.LanguageTag.fromstr("en"): "Simple command",
aioxmpp.structs.LanguageTag.fromstr("de"): "Einfacher Befehl",
},
self._trivial_handler,
)
session = await self.client_svc.execute(
self.server.local_jid,
"simple",
)
self.assertEqual(
len(session.response.notes),
1
)
self.assertEqual(session.response.notes[0].body, "some info!")
self.assertIsNone(session.first_payload)
await session.close()
@blocking_timed
async def test_properly_fail_for_unknown_command(self):
with self.assertRaises(aioxmpp.XMPPCancelError) as ctx:
session = await self.client_svc.execute(
self.server.local_jid,
"simple",
)
self.assertEqual(
ctx.exception.condition,
aioxmpp.ErrorCondition.ITEM_NOT_FOUND
)
tests/adhoc/test_service.py 0000664 0000000 0000000 00000111231 14160146213 0016344 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import contextlib
import random
import unittest
import unittest.mock
from datetime import timedelta
import aioxmpp
import aioxmpp.service
import aioxmpp.adhoc.service as adhoc_service
import aioxmpp.adhoc.xso as adhoc_xso
import aioxmpp.disco
from aioxmpp.utils import namespaces
from aioxmpp.testutils import (
make_connected_client,
CoroutineMock,
run_coroutine,
)
TEST_PEER_JID = aioxmpp.JID.fromstr("foo@bar.baz/fnord")
TEST_LOCAL_JID = aioxmpp.JID.fromstr("bar@bar.baz/fnord")
class TestAdHocClient(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.disco_service = unittest.mock.Mock()
self.disco_service.query_info = CoroutineMock()
self.disco_service.query_info.side_effect = AssertionError()
self.disco_service.query_items = CoroutineMock()
self.disco_service.query_items.side_effect = AssertionError()
self.c = adhoc_service.AdHocClient(
self.cc,
dependencies={
aioxmpp.disco.DiscoClient: self.disco_service,
}
)
def tearDown(self):
del self.c, self.cc
def test_is_service(self):
self.assertTrue(issubclass(
adhoc_service.AdHocClient,
aioxmpp.service.Service,
))
def test_depends_on_disco(self):
self.assertIn(
aioxmpp.disco.DiscoClient,
adhoc_service.AdHocClient.ORDER_AFTER,
)
def test_detect_support_using_disco(self):
response = aioxmpp.disco.xso.InfoQuery(
features={
"http://jabber.org/protocol/commands",
}
)
self.disco_service.query_info.side_effect = None
self.disco_service.query_info.return_value = response
self.assertTrue(
run_coroutine(self.c.supports_commands(TEST_PEER_JID)),
)
self.disco_service.query_info.assert_called_with(
TEST_PEER_JID,
)
def test_detect_absence_of_support_using_disco(self):
response = aioxmpp.disco.xso.InfoQuery(
features=set()
)
self.disco_service.query_info.side_effect = None
self.disco_service.query_info.return_value = response
self.assertFalse(
run_coroutine(self.c.supports_commands(TEST_PEER_JID)),
)
self.disco_service.query_info.assert_called_with(
TEST_PEER_JID,
)
def test_enumerate_commands_uses_disco(self):
items = [
aioxmpp.disco.xso.Item(
TEST_PEER_JID,
node="cmd{}".format(i),
)
for i in range(3)
]
response = aioxmpp.disco.xso.ItemsQuery(
items=list(items)
)
self.disco_service.query_items.side_effect = None
self.disco_service.query_items.return_value = response
result = run_coroutine(
self.c.get_commands(TEST_PEER_JID),
)
self.disco_service.query_items.assert_called_with(
TEST_PEER_JID,
node="http://jabber.org/protocol/commands",
)
self.assertSequenceEqual(
result,
items,
)
def test_get_command_info_uses_disco(self):
self.disco_service.query_info.side_effect = None
self.disco_service.query_info.return_value = \
unittest.mock.sentinel.result
result = run_coroutine(
self.c.get_command_info(TEST_PEER_JID,
unittest.mock.sentinel.node),
)
self.disco_service.query_info.assert_called_with(
TEST_PEER_JID,
node=unittest.mock.sentinel.node,
)
self.assertEqual(
result,
unittest.mock.sentinel.result,
)
def test_execute(self):
with unittest.mock.patch(
"aioxmpp.adhoc.service.ClientSession") as ClientSession:
ClientSession().start = CoroutineMock()
ClientSession.reset_mock()
result = run_coroutine(self.c.execute(
unittest.mock.sentinel.peer,
unittest.mock.sentinel.node,
))
ClientSession.assert_called_once_with(
self.cc.stream,
unittest.mock.sentinel.peer,
unittest.mock.sentinel.node,
)
ClientSession().start.assert_called_once_with()
self.assertEqual(result, ClientSession())
class TestCommandNode(unittest.TestCase):
def test_is_static_node(self):
self.assertTrue(issubclass(
adhoc_service.CommandEntry,
aioxmpp.disco.StaticNode,
))
def test_defaults(self):
stanza = unittest.mock.Mock()
cn = adhoc_service.CommandEntry(
"foo",
unittest.mock.sentinel.handler,
features={}
)
self.assertDictEqual(
cn.name,
{
None: "foo",
}
)
self.assertIsInstance(
cn.name,
aioxmpp.structs.LanguageMap,
)
self.assertEqual(
cn.handler,
unittest.mock.sentinel.handler
)
self.assertIn(
("automation", "command-node", None, "foo"),
list(cn.iter_identities(stanza))
)
self.assertCountEqual(
{
namespaces.xep0030_info,
namespaces.xep0050_commands,
},
cn.iter_features(unittest.mock.sentinel.stanza)
)
self.assertIsNone(
cn.is_allowed
)
self.assertTrue(
cn.is_allowed_for(unittest.mock.sentinel.jid)
)
def test_is_allowed_inhibits_identities_response(self):
stanza = unittest.mock.Mock()
is_allowed = unittest.mock.Mock()
is_allowed.return_value = False
cn = adhoc_service.CommandEntry(
"foo",
unittest.mock.sentinel.handler,
features={},
is_allowed=is_allowed
)
self.assertSequenceEqual([], list(cn.iter_identities(stanza)))
is_allowed.assert_called_once_with(
stanza.from_,
)
is_allowed.reset_mock()
is_allowed.return_value = True
self.assertIn(
("automation", "command-node", None, "foo"),
list(cn.iter_identities(stanza))
)
def test_is_allowed_for_calls_is_allowed_if_defined(self):
is_allowed = unittest.mock.Mock()
cn = adhoc_service.CommandEntry(
"foo",
unittest.mock.sentinel.handler,
is_allowed=is_allowed,
)
result = cn.is_allowed_for(
unittest.mock.sentinel.a,
unittest.mock.sentinel.b,
x=unittest.mock.sentinel.x,
)
is_allowed.assert_called_once_with(
unittest.mock.sentinel.a,
unittest.mock.sentinel.b,
x=unittest.mock.sentinel.x,
)
self.assertEqual(
result,
is_allowed(),
)
class TestAdHocServer(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.cc.local_jid = TEST_LOCAL_JID
self.disco_service = unittest.mock.Mock()
self.s = adhoc_service.AdHocServer(
self.cc,
dependencies={
aioxmpp.disco.DiscoServer: self.disco_service,
}
)
self.disco_service.reset_mock()
def tearDown(self):
del self.s
del self.disco_service
del self.cc
def test_is_service(self):
self.assertTrue(issubclass(
adhoc_service.AdHocServer,
aioxmpp.service.Service
))
def test_is_disco_node(self):
self.assertTrue(issubclass(
adhoc_service.AdHocServer,
aioxmpp.disco.Node
))
def test_registers_iq_handler(self):
self.assertTrue(
aioxmpp.service.is_iq_handler(
aioxmpp.IQType.SET,
adhoc_xso.Command,
adhoc_service.AdHocServer._handle_command,
)
)
def test_registers_as_node(self):
self.assertIsInstance(
adhoc_service.AdHocServer.disco_node,
aioxmpp.disco.mount_as_node,
)
self.assertEqual(
adhoc_service.AdHocServer.disco_node.mountpoint,
"http://jabber.org/protocol/commands"
)
def test_items_empty_by_default(self):
stanza = unittest.mock.Mock()
stanza.lang = None
self.assertSequenceEqual(
list(self.s.iter_items(stanza)),
[],
)
def test_identity(self):
self.assertSetEqual(
{
("automation", "command-list", None, None)
},
set(self.s.iter_identities(unittest.mock.sentinel.stanza))
)
def test_register_stateless_command_makes_it_appear_in_listing(self):
handler = unittest.mock.Mock()
base = unittest.mock.Mock()
base.CommandEntry().name.lookup.return_value = "some name"
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch(
"aioxmpp.adhoc.service.CommandEntry",
new=base.CommandEntry,
),
)
self.s.register_stateless_command(
"node",
unittest.mock.sentinel.name,
handler,
)
self.assertCountEqual(
[
(self.cc.local_jid, "node", "some name"),
],
[
(item.jid, item.node, item.name)
for item in self.s.iter_items(base.stanza)
]
)
def test_listing_respects_is_allowed(self):
handler = unittest.mock.Mock()
base = unittest.mock.Mock()
base.CommandEntry().name.lookup.return_value = "some name"
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch(
"aioxmpp.adhoc.service.CommandEntry",
new=base.CommandEntry),
)
self.s.register_stateless_command(
"node",
unittest.mock.sentinel.name,
handler,
)
base.CommandEntry().is_allowed_for.return_value = False
items = [
(item.jid, item.node, item.name)
for item in self.s.iter_items(base.stanza)
]
base.CommandEntry().is_allowed_for.assert_called_once_with(
base.stanza.from_,
)
self.assertCountEqual(
[
],
items,
)
def test_listing_respects_request_language(self):
handler = unittest.mock.Mock()
base = unittest.mock.Mock()
base.stanza.lang = "de"
base.CommandEntry().name.lookup.return_value = "some name"
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch(
"aioxmpp.adhoc.service.CommandEntry",
new=base.CommandEntry),
)
self.s.register_stateless_command(
"node",
unittest.mock.sentinel.name,
handler,
)
base.CommandEntry().is_allowed_for.return_value = True
items = [
(item.jid, item.node, item.name)
for item in self.s.iter_items(base.stanza)
]
base.CommandEntry().name.lookup.assert_called_once_with(
[
aioxmpp.structs.LanguageRange.fromstr("de"),
aioxmpp.structs.LanguageRange.fromstr("en"),
]
)
def test_register_stateless_registers_command_at_disco_service(self):
with contextlib.ExitStack() as stack:
CommandEntry = stack.enter_context(unittest.mock.patch(
"aioxmpp.adhoc.service.CommandEntry"
))
self.s.register_stateless_command(
unittest.mock.sentinel.node,
unittest.mock.sentinel.name,
unittest.mock.sentinel.handler,
features=unittest.mock.sentinel.features,
is_allowed=unittest.mock.sentinel.is_allowed,
)
CommandEntry.assert_called_once_with(
unittest.mock.sentinel.name,
unittest.mock.sentinel.handler,
features=unittest.mock.sentinel.features,
is_allowed=unittest.mock.sentinel.is_allowed,
)
self.disco_service.mount_node.assert_called_once_with(
unittest.mock.sentinel.node,
CommandEntry()
)
(_, (_, obj), _), = self.disco_service.mount_node.mock_calls
def test__handle_command_raises_item_not_found_for_unknown_node(self):
req = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
from_=TEST_PEER_JID,
to=TEST_LOCAL_JID,
payload=adhoc_xso.Command(
"node",
)
)
with self.assertRaises(aioxmpp.errors.XMPPCancelError) as ctx:
run_coroutine(self.s._handle_command(req))
self.assertEqual(
ctx.exception.condition,
aioxmpp.ErrorCondition.ITEM_NOT_FOUND,
)
self.assertRegex(
ctx.exception.text,
"no such command: 'node'"
)
def test__handle_command_raises_forbidden_for_disallowed_node(self):
handler = CoroutineMock()
handler.return_value = unittest.mock.sentinel.result
is_allowed = unittest.mock.Mock()
is_allowed.return_value = False
self.s.register_stateless_command(
"node",
"Command name",
handler,
is_allowed=is_allowed,
)
req = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
from_=TEST_PEER_JID,
to=TEST_LOCAL_JID,
payload=adhoc_xso.Command(
"node",
)
)
with self.assertRaises(aioxmpp.errors.XMPPCancelError) as ctx:
run_coroutine(self.s._handle_command(req))
is_allowed.assert_called_once_with(req.from_)
self.assertEqual(
ctx.exception.condition,
aioxmpp.ErrorCondition.FORBIDDEN,
)
handler.assert_not_called()
def test__handle_command_dispatches_to_command(self):
handler = CoroutineMock()
handler.return_value = unittest.mock.sentinel.result
self.s.register_stateless_command(
"node",
"Command name",
handler,
)
req = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
from_=TEST_PEER_JID,
to=TEST_LOCAL_JID,
payload=adhoc_xso.Command(
"node",
)
)
result = run_coroutine(self.s._handle_command(req))
handler.assert_called_once_with(req)
self.assertEqual(
result,
unittest.mock.sentinel.result,
)
class TestClientSession(unittest.TestCase):
def setUp(self):
self.stream = unittest.mock.Mock()
self.send_iq_and_wait_for_reply = CoroutineMock()
self.send_iq_and_wait_for_reply.return_value = None
self.stream.send_iq_and_wait_for_reply = \
self.send_iq_and_wait_for_reply
self.peer_jid = TEST_PEER_JID
self.command_name = "foocmd"
self.session = adhoc_service.ClientSession(
self.stream,
self.peer_jid,
self.command_name,
)
def tearDown(self):
del self.stream
del self.send_iq_and_wait_for_reply
del self.session
def test_init(self):
self.assertIsNone(
self.session.status,
)
self.assertIsNone(
self.session.first_payload,
)
self.assertIsNone(
self.session.response,
)
self.assertSetEqual(
self.session.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
)
def test_start(self):
response = unittest.mock.Mock()
self.send_iq_and_wait_for_reply.return_value = response
self.send_iq_and_wait_for_reply.side_effect = None
result = run_coroutine(self.session.start())
self.assertEqual(
len(self.send_iq_and_wait_for_reply.mock_calls),
1,
)
_, (iq,), _ = self.send_iq_and_wait_for_reply.mock_calls[0]
self.assertIsInstance(
iq,
aioxmpp.IQ,
)
self.assertEqual(
iq.type_,
aioxmpp.IQType.SET,
)
self.assertEqual(
iq.to,
self.peer_jid,
)
self.assertIsInstance(
iq.payload,
adhoc_xso.Command,
)
cmd = iq.payload
self.assertEqual(
cmd.action,
adhoc_xso.ActionType.EXECUTE,
)
self.assertEqual(
cmd.node,
self.command_name,
)
self.assertIsNone(cmd.actions)
self.assertIsNone(cmd.first_payload)
self.assertSequenceEqual(cmd.notes, [])
self.assertIsNone(cmd.status)
self.assertIsNone(cmd.sessionid)
self.assertEqual(
result,
response.first_payload
)
self.assertEqual(
self.session.first_payload,
response.first_payload
)
self.assertEqual(
self.session.response,
response
)
self.assertEqual(
self.session.status,
response.status,
)
self.assertEqual(
self.session.allowed_actions,
response.actions.allowed_actions,
)
self.assertEqual(
self.session.sessionid,
response.sessionid,
)
# trick
response.actions = None
self.assertSetEqual(
self.session.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
)
def test_aenter_starts(self):
with unittest.mock.patch.object(self.session, "start",
new=CoroutineMock()) as start:
result = run_coroutine(self.session.__aenter__())
start.assert_called_once_with()
self.assertEqual(result, self.session)
def test_aenter_after_start_is_harmless(self):
response = unittest.mock.Mock()
self.send_iq_and_wait_for_reply.return_value = response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
result = run_coroutine(self.session.__aenter__())
self.assertEqual(result, self.session)
def test_aexit_closes(self):
with unittest.mock.patch.object(self.session, "close",
new=CoroutineMock()) as close:
result = run_coroutine(self.session.__aexit__(
unittest.mock.sentinel.type_,
unittest.mock.sentinel.value,
unittest.mock.sentinel.tb,
))
close.assert_called_once_with()
self.assertFalse(result)
def test_reject_start_after_start(self):
response = unittest.mock.Mock()
self.send_iq_and_wait_for_reply.return_value = response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
with self.assertRaisesRegex(
RuntimeError,
r"command execution already started"):
run_coroutine(self.session.start())
self.assertSequenceEqual(
self.send_iq_and_wait_for_reply.mock_calls,
[]
)
def test_reject_proceed_before_start(self):
with self.assertRaisesRegex(
RuntimeError,
r"command execution not started yet"):
run_coroutine(self.session.proceed())
def test_proceed_uses_execute_and_previous_payload_by_default(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.sessionid = "foobar"
initial_response.actions = None
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
response = unittest.mock.Mock()
self.send_iq_and_wait_for_reply.return_value = response
self.send_iq_and_wait_for_reply.side_effect = None
result = run_coroutine(self.session.proceed())
_, (iq,), _ = self.send_iq_and_wait_for_reply.mock_calls[0]
self.assertIsInstance(
iq,
aioxmpp.IQ,
)
self.assertEqual(
iq.type_,
aioxmpp.IQType.SET,
)
self.assertEqual(
iq.to,
self.peer_jid,
)
self.assertIsInstance(
iq.payload,
adhoc_xso.Command,
)
cmd = iq.payload
self.assertEqual(
cmd.action,
adhoc_xso.ActionType.EXECUTE,
)
self.assertEqual(
cmd.node,
self.command_name,
)
self.assertIsNone(cmd.actions)
self.assertSequenceEqual(
cmd.payload,
initial_response.payload,
)
self.assertSequenceEqual(cmd.notes, [])
self.assertIsNone(cmd.status)
self.assertEqual(
cmd.sessionid,
"foobar",
)
self.assertEqual(
result,
response.first_payload
)
self.assertEqual(
self.session.first_payload,
response.first_payload
)
self.assertEqual(
self.session.response,
response
)
self.assertEqual(
self.session.status,
response.status,
)
self.assertEqual(
self.session.allowed_actions,
response.actions.allowed_actions,
)
def test_proceed_rejects_disallowed_action(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions.allowed_actions = set()
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
with self.assertRaisesRegex(
ValueError,
r"action .*NEXT not allowed in this stage"):
run_coroutine(self.session.proceed(
action=adhoc_xso.ActionType.NEXT
))
self.assertSequenceEqual(
self.send_iq_and_wait_for_reply.mock_calls,
[]
)
def test_proceed_with_custom_action(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions.allowed_actions = {
adhoc_xso.ActionType.NEXT,
}
initial_response.sessionid = "baz"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
response = unittest.mock.Mock()
self.send_iq_and_wait_for_reply.return_value = response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.proceed(
action=adhoc_xso.ActionType.NEXT,
))
_, (iq,), _ = self.send_iq_and_wait_for_reply.mock_calls[0]
cmd = iq.payload
self.assertEqual(
cmd.action,
adhoc_xso.ActionType.NEXT,
)
def test_proceed_with_custom_payload(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "fnord"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
response = unittest.mock.Mock()
self.send_iq_and_wait_for_reply.return_value = response
self.send_iq_and_wait_for_reply.side_effect = None
Command = adhoc_xso.Command
with unittest.mock.patch(
"aioxmpp.adhoc.xso.Command") as Command_patched:
Command_patched.side_effect = Command
run_coroutine(self.session.proceed(
payload=unittest.mock.sentinel.payload,
))
Command_patched.assert_called_with(
self.command_name,
action=adhoc_xso.ActionType.EXECUTE,
payload=unittest.mock.sentinel.payload,
sessionid=initial_response.sessionid,
)
_, (iq,), _ = self.send_iq_and_wait_for_reply.mock_calls[0]
cmd = iq.payload
self.assertSequenceEqual(
cmd.payload,
[unittest.mock.sentinel.payload],
)
def test_proceed_calls_close_and_reraises_on_BadSessionID(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "fnord"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
exc = aioxmpp.errors.XMPPModifyError(
aioxmpp.ErrorCondition.BAD_REQUEST,
text="Bad Session",
application_defined_condition=adhoc_xso.BadSessionID(),
)
self.send_iq_and_wait_for_reply.side_effect = exc
with unittest.mock.patch.object(
self.session,
"close",
new=CoroutineMock()) as close_:
with self.assertRaisesRegex(
adhoc_service.SessionError,
r"Bad Session"):
run_coroutine(self.session.proceed())
close_.assert_called_once_with()
def test_proceed_calls_close_and_reraises_on_SessionExpired(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "fnord"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
exc = aioxmpp.errors.XMPPCancelError(
aioxmpp.ErrorCondition.NOT_ALLOWED,
text="Session Expired",
application_defined_condition=adhoc_xso.SessionExpired(),
)
self.send_iq_and_wait_for_reply.side_effect = exc
with unittest.mock.patch.object(
self.session,
"close",
new=CoroutineMock()) as close_:
with self.assertRaisesRegex(
adhoc_service.SessionError,
r"Session Expired"):
run_coroutine(self.session.proceed())
close_.assert_called_once_with()
def test_proceed_closes_on_other_cancel_exceptions(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "fnord"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
exc = aioxmpp.errors.XMPPCancelError(
aioxmpp.ErrorCondition.FEATURE_NOT_IMPLEMENTED,
)
self.send_iq_and_wait_for_reply.side_effect = exc
with unittest.mock.patch.object(
self.session,
"close",
new=CoroutineMock()) as close_:
with self.assertRaises(aioxmpp.errors.XMPPCancelError):
run_coroutine(self.session.proceed())
close_.assert_called_once_with()
def test_proceed_reraises_other_modify_exceptions_without_closing(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "fnord"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
exc = aioxmpp.errors.XMPPModifyError(
aioxmpp.ErrorCondition.BAD_REQUEST,
)
self.send_iq_and_wait_for_reply.side_effect = exc
with unittest.mock.patch.object(
self.session,
"close",
new=CoroutineMock()) as close_:
with self.assertRaises(aioxmpp.errors.XMPPModifyError):
run_coroutine(self.session.proceed())
self.assertFalse(close_.mock_calls)
def test_allow_close_before_start(self):
run_coroutine(self.session.close())
self.assertIsNone(
self.session.status,
)
self.assertIsNone(
self.session.first_payload,
)
self.assertIsNone(
self.session.response,
)
self.assertSetEqual(
self.session.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
)
def test_close_after_start_sends_cancel_if_not_completed(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "funk"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
response = unittest.mock.Mock()
self.send_iq_and_wait_for_reply.return_value = response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.close())
_, (iq,), _ = self.send_iq_and_wait_for_reply.mock_calls[0]
self.assertIsInstance(
iq,
aioxmpp.IQ,
)
self.assertEqual(
iq.type_,
aioxmpp.IQType.SET,
)
self.assertEqual(
iq.to,
self.peer_jid,
)
self.assertIsInstance(
iq.payload,
adhoc_xso.Command,
)
cmd = iq.payload
self.assertEqual(
cmd.action,
adhoc_xso.ActionType.CANCEL,
)
self.assertEqual(
cmd.node,
self.command_name,
)
self.assertIsNone(cmd.actions)
self.assertSequenceEqual(
cmd.payload,
[],
)
self.assertSequenceEqual(cmd.notes, [])
self.assertIsNone(cmd.status)
self.assertEqual(
cmd.sessionid,
initial_response.sessionid,
)
self.assertIsNone(
self.session.status,
)
self.assertIsNone(
self.session.first_payload,
)
self.assertIsNone(
self.session.response,
)
self.assertSetEqual(
self.session.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
)
def test_close_ignores_stanza_errors_in_reply(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "funk"
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
exc = aioxmpp.errors.StanzaError()
self.send_iq_and_wait_for_reply.side_effect = exc
run_coroutine(self.session.close())
self.assertIsNone(
self.session.status,
)
self.assertIsNone(
self.session.first_payload,
)
self.assertIsNone(
self.session.response,
)
self.assertSetEqual(
self.session.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
)
def test_close_does_not_send_cancel_if_completed(self):
initial_response = unittest.mock.Mock()
initial_response.payload = [
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
initial_response.actions = None
initial_response.sessionid = "funk"
initial_response.status = adhoc_xso.CommandStatus.COMPLETED
self.send_iq_and_wait_for_reply.return_value = initial_response
self.send_iq_and_wait_for_reply.side_effect = None
run_coroutine(self.session.start())
self.send_iq_and_wait_for_reply.mock_calls.clear()
run_coroutine(self.session.close())
self.assertFalse(self.send_iq_and_wait_for_reply.mock_calls)
self.assertIsNone(
self.session.status,
)
self.assertIsNone(
self.session.first_payload,
)
self.assertIsNone(
self.session.response,
)
self.assertSetEqual(
self.session.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
)
# class TestServerSession(unittest.TestCase):
# def setUp(self):
# self.cc = make_connected_client()
# self.s = self.cc.stream
# self.sessionid = "testsessionid"
# self.ss = adhoc_service.ServerSession(
# self.s,
# sessionid=self.sessionid
# )
# def tearDown(self):
# del self.ss
# del self.sessionid
# del self.s
# del self.cc
# def test_init_uses_system_entropy(self):
# self.assertIsInstance(
# adhoc_service._rng,
# random.SystemRandom,
# )
# with unittest.mock.patch("aioxmpp.adhoc.service._rng") as rng:
# rng.getrandbits.return_value = 1234
# self.ss = adhoc_service.ServerSession(
# self.s,
# TEST_PEER_JID,
# )
# rng.getrandbits.assert_called_once_with(64)
# self.assertEqual(
# self.ss.sessionid,
# "0gQAAAAAAAA"
# )
# def test_init(self):
# self.assertEqual(self.ss.sessionid, self.sessionid)
# self.assertEqual(self.ss.timeout, timedelta(seconds=60))
# def test_reply_raises_if_handle_has_not_been_called(self):
# with self.assertRaises(RuntimeError):
# pass
tests/adhoc/test_xso.py 0000664 0000000 0000000 00000036061 14160146213 0015524 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import unittest
import unittest.mock
import aioxmpp
import aioxmpp.adhoc.xso as adhoc_xso
import aioxmpp.forms.xso as forms_xso
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
class TestNamespaces(unittest.TestCase):
def test_commands_namespace(self):
self.assertEqual(
namespaces.xep0050_commands,
"http://jabber.org/protocol/commands"
)
class TestNote(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(
adhoc_xso.Note,
xso.XSO,
))
def test_tag(self):
self.assertEqual(
adhoc_xso.Note.TAG,
(namespaces.xep0050_commands, "note"),
)
def test_body_attr(self):
self.assertIsInstance(
adhoc_xso.Note.body,
xso.Text,
)
self.assertIsNone(
adhoc_xso.Note.body.default,
)
def test_type__attr(self):
self.assertIsInstance(
adhoc_xso.Note.type_,
xso.Attr,
)
self.assertEqual(
adhoc_xso.Note.type_.tag,
(None, "type"),
)
self.assertIsInstance(
adhoc_xso.Note.type_.type_,
xso.EnumCDataType,
)
self.assertEqual(
adhoc_xso.Note.type_.type_.enum_class,
adhoc_xso.NoteType,
)
self.assertEqual(
adhoc_xso.Note.type_.default,
adhoc_xso.NoteType.INFO,
)
def test_init(self):
n = adhoc_xso.Note(
adhoc_xso.NoteType.INFO,
"foo",
)
self.assertEqual(n.type_, adhoc_xso.NoteType.INFO)
self.assertEqual(n.body, "foo")
class TestActions(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(
adhoc_xso.Actions,
xso.XSO,
))
def test_tag(self):
self.assertEqual(
adhoc_xso.Actions.TAG,
(namespaces.xep0050_commands, "actions"),
)
def test_next_is_allowed_attr(self):
self.assertIsInstance(
adhoc_xso.Actions.next_is_allowed,
xso.ChildFlag,
)
self.assertEqual(
adhoc_xso.Actions.next_is_allowed.tag,
(namespaces.xep0050_commands, "next"),
)
def test_prev_is_allowed_attr(self):
self.assertIsInstance(
adhoc_xso.Actions.prev_is_allowed,
xso.ChildFlag,
)
self.assertEqual(
adhoc_xso.Actions.prev_is_allowed.tag,
(namespaces.xep0050_commands, "prev"),
)
def test_complete_is_allowed_attr(self):
self.assertIsInstance(
adhoc_xso.Actions.complete_is_allowed,
xso.ChildFlag,
)
self.assertEqual(
adhoc_xso.Actions.complete_is_allowed.tag,
(namespaces.xep0050_commands, "complete"),
)
def test_execute_attr(self):
self.assertIsInstance(
adhoc_xso.Actions.execute,
xso.Attr,
)
self.assertEqual(
adhoc_xso.Actions.execute.tag,
(None, "execute"),
)
self.assertIsInstance(
adhoc_xso.Actions.execute.type_,
xso.EnumCDataType,
)
self.assertEqual(
adhoc_xso.Actions.execute.type_.enum_class,
adhoc_xso.ActionType,
)
self.assertIsInstance(
adhoc_xso.Actions.execute.validator,
xso.RestrictToSet,
)
self.assertSetEqual(
adhoc_xso.Actions.execute.validator.values,
{
adhoc_xso.ActionType.NEXT,
adhoc_xso.ActionType.PREV,
adhoc_xso.ActionType.COMPLETE,
}
)
self.assertEqual(
adhoc_xso.Actions.execute.default,
None,
)
def test_allowed_actions(self):
actions = adhoc_xso.Actions()
self.assertSetEqual(
actions.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
)
actions.prev_is_allowed = True
self.assertSetEqual(
actions.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.PREV},
)
actions.prev_is_allowed = False
actions.next_is_allowed = True
self.assertSetEqual(
actions.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.NEXT},
)
actions.next_is_allowed = False
actions.complete_is_allowed = True
self.assertSetEqual(
actions.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.COMPLETE},
)
actions.next_is_allowed = True
actions.prev_is_allowed = True
self.assertSetEqual(
actions.allowed_actions,
{adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.NEXT,
adhoc_xso.ActionType.PREV,
adhoc_xso.ActionType.COMPLETE},
)
self.assertIsInstance(
actions.allowed_actions,
frozenset
)
def test_set_allowed_actions_rejects_if_EXECUTE_is_missing(self):
actions = adhoc_xso.Actions()
with self.assertRaisesRegex(
ValueError,
r"EXECUTE must always be allowed"):
actions.allowed_actions = {
adhoc_xso.ActionType.CANCEL
}
def test_set_allowed_actions_rejects_if_CANCEL_is_missing(self):
actions = adhoc_xso.Actions()
with self.assertRaisesRegex(
ValueError,
r"CANCEL must always be allowed"):
actions.allowed_actions = {
adhoc_xso.ActionType.EXECUTE,
}
def test_set_allowed_actions(self):
actions = adhoc_xso.Actions()
actions.prev_is_allowed = True
actions.next_is_allowed = True
actions.complete_is_allowed = True
actions.allowed_actions = {
adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
}
self.assertFalse(actions.prev_is_allowed)
self.assertFalse(actions.next_is_allowed)
self.assertFalse(actions.complete_is_allowed)
actions.allowed_actions = {
adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.NEXT,
}
self.assertFalse(actions.prev_is_allowed)
self.assertTrue(actions.next_is_allowed)
self.assertFalse(actions.complete_is_allowed)
actions.allowed_actions = {
adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.PREV,
}
self.assertTrue(actions.prev_is_allowed)
self.assertFalse(actions.next_is_allowed)
self.assertFalse(actions.complete_is_allowed)
actions.allowed_actions = {
adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.COMPLETE,
}
self.assertFalse(actions.prev_is_allowed)
self.assertFalse(actions.next_is_allowed)
self.assertTrue(actions.complete_is_allowed)
actions.allowed_actions = {
adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL,
adhoc_xso.ActionType.NEXT,
adhoc_xso.ActionType.PREV,
adhoc_xso.ActionType.COMPLETE,
}
self.assertTrue(actions.prev_is_allowed)
self.assertTrue(actions.next_is_allowed)
self.assertTrue(actions.complete_is_allowed)
class TestCommand(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(
adhoc_xso.Command,
xso.XSO,
))
def test_is_iq_payload(self):
self.assertIn(
adhoc_xso.Command.TAG,
aioxmpp.IQ.CHILD_MAP,
)
def test_tag(self):
self.assertEqual(
adhoc_xso.Command.TAG,
(namespaces.xep0050_commands, "command"),
)
def test_actions_attr(self):
self.assertIsInstance(
adhoc_xso.Command.actions,
xso.Child,
)
self.assertCountEqual(
adhoc_xso.Command.actions._classes,
[
adhoc_xso.Actions,
]
)
def test_notes_attr(self):
self.assertIsInstance(
adhoc_xso.Command.notes,
xso.ChildList,
)
self.assertCountEqual(
adhoc_xso.Command.notes._classes,
[
adhoc_xso.Note,
]
)
def test_action_attr(self):
self.assertIsInstance(
adhoc_xso.Command.action,
xso.Attr,
)
self.assertEqual(
adhoc_xso.Command.action.tag,
(None, "action"),
)
self.assertIsInstance(
adhoc_xso.Command.action.type_,
xso.EnumCDataType,
)
self.assertEqual(
adhoc_xso.Command.action.type_.enum_class,
adhoc_xso.ActionType,
)
self.assertEqual(
adhoc_xso.Command.action.default,
adhoc_xso.ActionType.EXECUTE,
)
def test_status_attr(self):
self.assertIsInstance(
adhoc_xso.Command.status,
xso.Attr,
)
self.assertEqual(
adhoc_xso.Command.status.tag,
(None, "status"),
)
self.assertIsInstance(
adhoc_xso.Command.status.type_,
xso.EnumCDataType,
)
self.assertEqual(
adhoc_xso.Command.status.type_.enum_class,
adhoc_xso.CommandStatus,
)
self.assertIsNone(
adhoc_xso.Command.status.default,
)
def test_sessionid_attr(self):
self.assertIsInstance(
adhoc_xso.Command.sessionid,
xso.Attr,
)
self.assertEqual(
adhoc_xso.Command.sessionid.tag,
(None, "sessionid"),
)
self.assertIsNone(
adhoc_xso.Command.sessionid.default,
)
def test_node_attr(self):
self.assertIsInstance(
adhoc_xso.Command.node,
xso.Attr,
)
self.assertEqual(
adhoc_xso.Command.node.tag,
(None, "node"),
)
self.assertEqual(
adhoc_xso.Command.node.default,
xso.NO_DEFAULT,
)
def test_payload_attr(self):
self.assertIsInstance(
adhoc_xso.Command.payload,
xso.ChildList,
)
self.assertIn(
forms_xso.Data,
adhoc_xso.Command.payload._classes,
)
def test_init_default(self):
with self.assertRaisesRegex(
TypeError,
r"required positional argument: 'node'"):
adhoc_xso.Command()
def test_init(self):
cmd = adhoc_xso.Command(node="foo")
self.assertEqual(cmd.node, "foo")
self.assertEqual(cmd.action, adhoc_xso.ActionType.EXECUTE)
self.assertIsNone(cmd.status)
self.assertIsNone(cmd.sessionid)
self.assertSequenceEqual(cmd.payload, [])
self.assertSequenceEqual(cmd.notes, [])
self.assertIsNone(cmd.actions)
self.assertIsNone(cmd.first_payload)
def test_init_full(self):
cmd = adhoc_xso.Command(
node="foo",
action=adhoc_xso.ActionType.COMPLETE,
status=adhoc_xso.CommandStatus.EXECUTING,
sessionid="foobar",
payload=[
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
],
notes=[
unittest.mock.sentinel.note1,
unittest.mock.sentinel.note2,
],
actions=unittest.mock.sentinel.actions,
)
self.assertEqual(cmd.node, "foo")
self.assertEqual(cmd.action, adhoc_xso.ActionType.COMPLETE)
self.assertEqual(cmd.status, adhoc_xso.CommandStatus.EXECUTING)
self.assertEqual(cmd.sessionid, "foobar")
self.assertSequenceEqual(
cmd.payload,
[
unittest.mock.sentinel.payload1,
unittest.mock.sentinel.payload2,
]
)
self.assertSequenceEqual(
cmd.notes,
[
unittest.mock.sentinel.note1,
unittest.mock.sentinel.note2,
]
)
self.assertEqual(cmd.actions, unittest.mock.sentinel.actions)
self.assertEqual(cmd.first_payload, unittest.mock.sentinel.payload1)
def test_init_single_payload(self):
cmd = adhoc_xso.Command(
"foo",
payload=unittest.mock.sentinel.payload1,
)
self.assertSequenceEqual(
cmd.payload,
[
unittest.mock.sentinel.payload1,
]
)
self.assertEqual(cmd.first_payload, unittest.mock.sentinel.payload1)
class TestSimpleErrors(unittest.TestCase):
ERROR_CLASSES = [
("MalformedAction", "malformed-action"),
("BadAction", "bad-action"),
("BadLocale", "bad-locale"),
("BadPayload", "bad-payload"),
("BadSessionID", "bad-sessionid"),
("SessionExpired", "session-expired"),
]
def _run_tests(self, func):
for clsname, *args in self.ERROR_CLASSES:
cls = getattr(adhoc_xso, clsname)
func(cls, args)
def _test_is_xso(self, cls, args):
self.assertTrue(issubclass(
cls,
xso.XSO
))
def test_is_xso(self):
self._run_tests(self._test_is_xso)
def _test_tag(self, cls, args):
self.assertEqual(
("http://jabber.org/protocol/commands", args[0]),
cls.TAG
)
def test_tag(self):
self._run_tests(self._test_tag)
def _test_is_application_error(self, cls, args):
self.assertIn(
cls,
aioxmpp.stanza.Error.application_condition._classes
)
def test_is_application_error(self):
self._run_tests(self._test_is_application_error)
tests/avatar/ 0000775 0000000 0000000 00000000000 14160146213 0013474 5 ustar 00root root 0000000 0000000 tests/avatar/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0015612 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/avatar/test_e2e.py 0000664 0000000 0000000 00000021474 14160146213 0015570 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_e2e.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
# .
#
########################################################################
import asyncio
import base64
import logging
import aioxmpp.avatar
import aioxmpp.e2etest
from aioxmpp.e2etest import (
blocking,
blocking_timed,
TestCase,
require_pep,
)
TEST_IMAGE = base64.decodebytes(b"""
iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI
WXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH1wEPAzgt9urhBgAAERlJREFUeNrt3XuwVtV9h/HncLhY
L0c0lUwxgxEV0TEVxKTUUpFbm9Z6GaeNUWKrZlrFRMeYyUTiLUEbmHaiTknFNDdtY73UsVFiklYu
ahOLjVYcYwRqFBjRRKpcAkrkcvrHWgznHKmHc867915r7+cz847z4px3rf1be3/ffXv3AkmSJEmS
JEmSJEmSJEmSJEmSJEmSJEmSlJq2Fn3ODGCa5ZRKsxh4JIUAmA78ewvDRFLvOoE/ABYN5EMGtaAj
U9z4pUr23qcM9ENaEQCDHAupEgPe9gYX0KkpwFrHppuZwJwu788FnrIs3ZwM3Nvl/fXAXZalm1HA
0lZ+YBEB8JIB8C7/2+P9q7FO2mPkXmpmjbrbkdwuhKQGH0NIMgAkGQCSDABJBoAkA0BSogZbgsYY
DYwHxgBHAsOBjvj/NgMbgZeBVcAzeA3eAFD2e3dTgfMIv9Q8oo9/v4bwi7O7gSXALkvqIYDS1wHM
JtyN+QhwcT82fuLfXBw/Y238zA7LawAoTUOBq4HVwJeBw1v42YfHz1wd2xhquQ0ApWMS8CwwFzik
wHYOiW08G9uUAaCKx+864FFgbIntjo1tXuc6ZACoGvsBDxB+ZtxeQfvtse0HYl9kAKgkBwM/BM5K
oC9nxb4c7LAYACre0PitOzmhPk2OffLkoAGgArURrstPTbBvU2PffD6kAaCCXAWck3D/zol9lAGg
FptAuBafui/HvsoAUIu0A1/L5Bh7aOxru8NmAKg1/jKzb9UJsc8yANSCb9RrM+z3tXhVwADQgF1I
a+/rL8vhse8yADQAs+y7DIBm+hAwLuP+j4vLIANA/fCxGizDnzmMBoD6Z2oNlmGaw2gAqO8OAD5c
g+X4cFwWGQDqg7HAkBosxxDKfVaBDIDaBIDLIgOgoUa5LDIAmqvDZZEBYAC4LDIAGmiHyyIDoLm2
uCwyAJrrTZdFBkBzveiyyABorlUuiwyAZgfAphosxyYDwABQ3+0EHq/Bcjwel0UGgProEZdBBkBz
3Uve19B3xGWQAaB+eJ0w716ufhiXQQaA+ulW+y4DoLkWA8sy7Pey2HcZABqgq4HOjPrbGfssA0At
8BjwnYz6+53YZxkAapHPAq9m0M9XY19lAKiF1gPnk/ZNNTtjH9c7XAaAijkUuDzh/l3urr8BoGIt
AOYl2K95sW8yAFSw2cCchPozJ/ZJBoBKcgNh8s3tFfZhe+zDDQ6HAaDy3Q6cCqytoO21se3bHQYD
QNVZBpwIfBXYVUJ7u2Jbv02edyjKAKidjYQz8CcDD1PMXYOd8bNPjm1tsuwGgNLyDPAnwATg2y3a
SDfFz5oQP/sZy1wfgy1BbYPgYuBTwOmEKbqnAGOAtn34pl8FLCX8mOdh4G1LagAoP28D98cXhGm6
xwAfBA4BDoz/vgXYAKyOG/9WS2cAqH62xr0Dd+PlOQDJAJBkAEgyACQZAJIMAEkGgKR6KuI+gPNx
PvieJvV4fyZwvGXp5ui91Mw5Bbs7NIcAmOs49epzlmCfvkjOtwweAkgyACTlcAgwEyeE7OkM4Iou
7z8D/NSydHMCcEuX938HLLQs3YwA7ko9AH5ENY+oStkxPd4/FeukPbb1eL8CWGRZuhnlIYAkA0CS
ASDJAJBkAEgyACQZAJJ64UNBpT3aCfdsjAVGA+8jPDl5CLCZMEfCGmAl4T6FrQaAlLcRwLnADGAy
0LGPf7cDeJIwf8J9wHMGgJSPqcBn44Y/pJ/bzu/F17XAcmABcAfwjucApDSdCvwnYdajP+7nxr83
44CvAT8nTJk+yACQ0nEY8I/Ao8DEAtv5AHAb8F+EiVQNAKlikwmzIV1A73MjtsoE4AnCLz/bDACp
GpfH3f3DK2h7CHAz4SThMANAKtdNhOcKtFfcjz8FfsC+X2EwAKQBuhG4JqH+TAEeBPY3AKRizSJc
mkvNacDdKW13BoDqZiJwa8L9OxOYbQBIrTccuAcYmng/vwT8vgEgtdZNwBEZ9LMd+IcUgsoAUF2c
BFyaUX/HEu4RMACkFphD9Zf7+upqKr40aACoDsYR7uvPzXDgMgNAGpjLSfh22158qsrt0ABQ7vYn
3GmXqw8QfppsAEj98EckeIttH51nAEj9M70GyzDNAJD6Z0oNluEIwjMIDQCpDw4AxtRkWcYZAFLf
HEO+Z/97OtYAkPrm6Boty1EGgNQ3B9VoWToMAKm5AXCgASDJAJD20a9qtCxbDACpuQGw2QCQ+ubF
Gi3Lzw0AqW/+B+isybKsNACkvtkKrKrJsiw3AKS+W1qDZVgDvGQASH23qAbLsLiqhg0A5e4HVHQG
vYXuNgCk/nkLuD/j/r8CLDEApP6bT75XA/4e2GUASP23HPh+hv3eCNxWZQcMANXF9cDOzPo8j4rP
XxgAqov/Bm7PqL8rgFuq7oQBoDq5lnBNPXU7gb8C3jEApNYeU388hQ2rFzcA/5FCRwwA1c0y4MqE
+/cQMDeVzhgAqqMFhKnCU/MoYRKQXQaAVKzrgL9OqD9LgbMINy5hAEjFuxa4guovD95PmMIsuVuW
DQDV3XzC1FvrKmh7O3AV8DHg1ykWxwBQEzwGjAf+ifJuGX4aOIVwrT/Z25QNADXFeuDPgdMIVwqK
8gpwGfAR4KnUi2IAqGkeB343HhZ8P+6mt8Jy4BLCDD8LSOhM/3sZ7PqghloSXyOAc4EZwGT2fYae
HcCThLP79wHP5VgEA0BN9zrhROF8oJ0w4ehYwnTd7yPM2DOEcAZ/E+FW45WEe/m35r7wBoC0x864
Ya9oygJ7DkBqMANAMgAkGQCSDABJBoAkA0BSXRVxH8Akws0V2mNsj/cnA/tZlm5O2EvNpluWbkbk
EAB3OU69usUS9OqK+JKHAJIMAEnJHwLMBt60tN1MBs7v8v5vgRctSzdHA5/r8v6fCQ/y0B6HktAT
hXebS3jiye7XKMfpXWb1qNEkS/Iuk3rUaJYleZdRPWo04DDwEEDyHIAkA0CSASDJAJBkAEgyACTV
Tp0eCnowcCzhWukhwAGERzdvATYQbrx5kUSnaCrJAcAY4IOxRgfGf99do9XAKmrwtNsBGEa4Keno
LjUaHGuyAVhLeCrwJgOgWsOBMwi/GDuNfbsBaSfwU8Lz4P8t/nd7jVfm3wBOJ0yCMSVu/G29/E1n
DIGlwGLgYeDtGtdoCDAV+MP43xMIjwfvzVrCdN+LgIXAxqYmZtl3Ak4hTMSwrUe7/Xn9EriZ8Az4
IpV9J+B44FtxpRxojTbGzxpfcJ/LvhNwdBz7X7agRtviOjml4D63/E7AnAJgOvDjFgzW3l7bCRNH
js48AMYD3yNMS9XqGu2Knz0+8wAYHcd6e0Hr0o8p7jkGjQyAw2O6dpbwegu4Ph4H5hQAwwkz2+ws
oUY7Y1vDMwuAYXFs3yppXbovrrsGwACcDbxR0oB1fT1DmCIqhwCYSJiuquwarYlt5xAAx8QxLbtG
b8R1ONkASPUyYBswD3iA8BPIso0jTO18euLnXy4lzHZbxS8wR8W2L028RqfHsRxXQduHxnV4Hr2f
fK1EigEwBLgT+HzFResAvgtcmOiK/SXCNNRDKh6rBbEvKbowjmFHhX1oi+vynRWP1V6ldhmwDfg2
MDOh+nwr7m7dmVCd5gJXJ9Sf64GhhIfBpOIv4til8s17QfzCvSCuT+4B7MVXE9r4u4bSN4EzE+nP
rMQ2/t2uJp2HeJwZxyy13e6ZcR33EOD/SezLEt2VbCdcOjqm4n5MJpyBT9X82McqHRPHqj3RGl0W
13UDoIvjgdtIWwdwL62/RLivDiM8J6894Rq1xz4eVlH7w+IYdSS+Lt0W13kDIO6mfR3Yn/SNJ5zQ
qcJXgJEZ1Ghk7GsVPk/xdyy2wv5xnW8zAOAi4BTyMRs4soJd/09kVKNPVHAocCRpnYTszSlx3W90
AAwDbiQv+wFzSm4z2evI77FXN6/kNueQ33RrN1Z4SJlEAFyUyW5tTx+n+B8Q7TaN1t5xV5aJse9l
GB3HJDcjq94LqDoAriRPg4FPW6Nk+v5p8v1p+5VNDYDfITzAI1czS1jpRgAfzbhGH6WAGW33EsYz
M67RsXFbaFwAnE/eRgAzCm7jXPJ+aMvguAxFmlFCyNR2W6gyAGaQv+nWqPJlmG6N8guAkcBxNRi4
Ik9ytQOn1qBGp1LszUvTalCj46joZHhVAXAS9XAC4UcwRRhDeNBp7g6Oy1KEoXEM6uCkJgXAcTUZ
tPYCV+4x1EeRNWqvSY2Oa1IAHFWjlfvozD7XGqXpqCYFwPAaDVxRu+mH1qhGh2ZW+8ZsE1UFwIE1
GriDrFFly3KQNcozAOo0I9Fga2SNcl2WqgJgS40G7lcFfe7mGtVoc2a1b8w2MahmG02dBs4A8Iuk
tgGwukYD93JBn7u2RjVam1ntG7NNVBUAq2o0cCsL+twVNarRisxq35htoqoAeLYmg7aG4qaJXkE9
Zi7eXmAAbIpjUAfPNikAXgBer8GgPVbgZ28FflKDGv0kLkuOY1CW1+M20ZgA6ASW1GDgFhX8+XWo
0eLMx6AMS6hospAqfw78L5kP2jZgYcFt3FeDlbvocV4Yx8IaZRYA3wPezHjQFgIbC27jOWB5xjVa
HpehSBtLCOIivRm3hcYFwDvANzIeuNtLamdBxjVaULOxKMI34rbQuAAAuAV4O8NBe6LE4/M7gHUZ
1mhd7HtZx9BPZFijt+M2QFMD4BeZfsN9seQ9pZsyrNFNJX+zfTHDGi2I20BjAwDC3PI5fcPdBzxS
cptfB57OqEZPxz6X6RHyOrG8Lq77ND0ANpPPs+/fBK6qoN2dwCVVHiv2cY/lktjnsn2GfE4sX0kC
v/dIZXbg+zM4FOgkzOJS1d7K08AXMlixv1Dh3sq6OEadiddoQVznMQD2uAp4MuFBmwc8VHEfbgYe
SLhGD8Q+Vukhyp+XsC+erGgvMvkA2AacQZo/FLoLuCaRvZDzSPMOwSWxbyl8+14Txyw1q+I6vs0A
2Lv1hOe8p/RLuHuAixParXwHOIe07oF/LPYplXMUnXHM7kmoRiviur0+pQ0utQAAeAWYlMjhwHzC
vHOpnXzbRJh378EE+vJg7MumxGr0Thy7+Yns9k+K6zYGQO/eAE6juju83iKcTLoC2JVojbbFb93r
qeaM+87Y9jmkey/+rjiGF8UxrcLtcV1+I8UCpRoAu1fwWXEFe63Edp8APkJ5d7ENdAW/Ma5gZR42
rYht3phwQHZ1RxzTMu8WfC2uu7MSDsikA2C3fyVMofyVggu5Dvhk3FV7nrz8CDgRmA1sKLCdDbGN
E2ObOXk+ju0nKfZS7ra4rh4b193am0s46bL7NarAtt4P/E3cneps0esFwo0rwwrs96webU4qsK2O
uJG+0sIavRI/s6PAfk/q0easAtsaFsf8hRbW6I24br6/wH6P6tHm3KYFQNcBPBu4m3AvdV8H62fA
rcDEkmpUZgB03bubDnyT8MDJvtZodfzb6SXtKZYZAF1NjOvCz/pRo1/EdfDsgr9ACguAXCdW+DXw
3fhqA44HPhR3u44gzLJyCOF5dFsIl15eJjxE8qmSzylUeX5gEXuemDMaGE+YUPNIwlRUu7/RNxN+
V/8y4Vr1M8BLNMOy+AL4LeDkuB4dCRwW16Uh8fBnC+EZhCsJzznYHRrZqsPMKp3x+O559F5eatBG
3V+vER4usrApCzzIMZeaywCQDABJBoAkA0CSASDJAJBUV0XcBzCaetxf0Eq/2eP9yFgnda9Jz5pZ
o+5G5RAASx2nXt1rCXo1J76U+CHALssoVWJXCgGwlMzvh5Yy1NmKve22FnVmBuF5Z5LKsZjyJ6iR
JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEkl+D+B9JFB0vqGWgAAAABJRU5ErkJggg==
""")
class TestAvatar(TestCase):
@require_pep
@blocking
async def setUp(self):
self.client, = await asyncio.gather(
self.provisioner.get_connected_client(
services=[
aioxmpp.EntityCapsService,
aioxmpp.PresenceServer,
aioxmpp.avatar.AvatarService,
]
),
)
@blocking_timed
async def test_provide_and_retrieve_avatar(self):
avatar_impl = self.client.summon(aioxmpp.avatar.AvatarService)
avatar_set = aioxmpp.avatar.AvatarSet()
avatar_set.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
await avatar_impl.publish_avatar_set(avatar_set)
avatar_info = await avatar_impl.get_avatar_metadata(
self.client.local_jid.bare()
)
self.assertEqual(
len(avatar_info),
1
)
info = avatar_info[0]
test_image_retrieved = await info.get_image_bytes()
self.assertEqual(
test_image_retrieved,
TEST_IMAGE
)
@blocking_timed
async def test_on_metadata_changed(self):
avatar_impl = self.client.summon(aioxmpp.avatar.AvatarService)
done_future = asyncio.Future()
def handler(jid, metadata):
done_future.set_result((jid, metadata))
avatar_impl.on_metadata_changed.connect(handler)
avatar_set = aioxmpp.avatar.AvatarSet()
avatar_set.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
logging.info("publishing avatar")
await avatar_impl.publish_avatar_set(avatar_set)
logging.info("waiting for completion")
jid, metadata = await done_future
self.assertEqual(jid, self.client.local_jid.bare())
self.assertEqual(len(metadata), 1)
tests/avatar/test_service.py 0000664 0000000 0000000 00000144263 14160146213 0016557 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import asyncio
import base64
import contextlib
import hashlib
import unittest
import aioxmpp
import aioxmpp.errors as errors
import aioxmpp.avatar.service as avatar_service
import aioxmpp.avatar.xso as avatar_xso
import aioxmpp.pubsub.xso as pubsub_xso
import aioxmpp.vcard
import aioxmpp.vcard.xso as vcard_xso
from aioxmpp.utils import namespaces
from aioxmpp.testutils import (
make_connected_client,
CoroutineMock,
run_coroutine,
)
TEST_IMAGE = base64.decodebytes(b"""
iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI
WXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH1wEPAzgt9urhBgAAERlJREFUeNrt3XuwVtV9h/HncLhY
L0c0lUwxgxEV0TEVxKTUUpFbm9Z6GaeNUWKrZlrFRMeYyUTiLUEbmHaiTknFNDdtY73UsVFiklYu
ahOLjVYcYwRqFBjRRKpcAkrkcvrHWgznHKmHc867915r7+cz847z4px3rf1be3/ffXv3AkmSJEmS
JEmSJEmSJEmSJEmSJEmSJEmSlJq2Fn3ODGCa5ZRKsxh4JIUAmA78ewvDRFLvOoE/ABYN5EMGtaAj
U9z4pUr23qcM9ENaEQCDHAupEgPe9gYX0KkpwFrHppuZwJwu788FnrIs3ZwM3Nvl/fXAXZalm1HA
0lZ+YBEB8JIB8C7/2+P9q7FO2mPkXmpmjbrbkdwuhKQGH0NIMgAkGQCSDABJBoAkA0BSogZbgsYY
DYwHxgBHAsOBjvj/NgMbgZeBVcAzeA3eAFD2e3dTgfMIv9Q8oo9/v4bwi7O7gSXALkvqIYDS1wHM
JtyN+QhwcT82fuLfXBw/Y238zA7LawAoTUOBq4HVwJeBw1v42YfHz1wd2xhquQ0ApWMS8CwwFzik
wHYOiW08G9uUAaCKx+864FFgbIntjo1tXuc6ZACoGvsBDxB+ZtxeQfvtse0HYl9kAKgkBwM/BM5K
oC9nxb4c7LAYACre0PitOzmhPk2OffLkoAGgArURrstPTbBvU2PffD6kAaCCXAWck3D/zol9lAGg
FptAuBafui/HvsoAUIu0A1/L5Bh7aOxru8NmAKg1/jKzb9UJsc8yANSCb9RrM+z3tXhVwADQgF1I
a+/rL8vhse8yADQAs+y7DIBm+hAwLuP+j4vLIANA/fCxGizDnzmMBoD6Z2oNlmGaw2gAqO8OAD5c
g+X4cFwWGQDqg7HAkBosxxDKfVaBDIDaBIDLIgOgoUa5LDIAmqvDZZEBYAC4LDIAGmiHyyIDoLm2
uCwyAJrrTZdFBkBzveiyyABorlUuiwyAZgfAphosxyYDwABQ3+0EHq/Bcjwel0UGgProEZdBBkBz
3Uve19B3xGWQAaB+eJ0w716ufhiXQQaA+ulW+y4DoLkWA8sy7Pey2HcZABqgq4HOjPrbGfssA0At
8BjwnYz6+53YZxkAapHPAq9m0M9XY19lAKiF1gPnk/ZNNTtjH9c7XAaAijkUuDzh/l3urr8BoGIt
AOYl2K95sW8yAFSw2cCchPozJ/ZJBoBKcgNh8s3tFfZhe+zDDQ6HAaDy3Q6cCqytoO21se3bHQYD
QNVZBpwIfBXYVUJ7u2Jbv02edyjKAKidjYQz8CcDD1PMXYOd8bNPjm1tsuwGgNLyDPAnwATg2y3a
SDfFz5oQP/sZy1wfgy1BbYPgYuBTwOmEKbqnAGOAtn34pl8FLCX8mOdh4G1LagAoP28D98cXhGm6
xwAfBA4BDoz/vgXYAKyOG/9WS2cAqH62xr0Dd+PlOQDJAJBkAEgyACQZAJIMAEkGgKR6KuI+gPNx
PvieJvV4fyZwvGXp5ui91Mw5Bbs7NIcAmOs49epzlmCfvkjOtwweAkgyACTlcAgwEyeE7OkM4Iou
7z8D/NSydHMCcEuX938HLLQs3YwA7ko9AH5ENY+oStkxPd4/FeukPbb1eL8CWGRZuhnlIYAkA0CS
ASDJAJBkAEgyACQZAJJ64UNBpT3aCfdsjAVGA+8jPDl5CLCZMEfCGmAl4T6FrQaAlLcRwLnADGAy
0LGPf7cDeJIwf8J9wHMGgJSPqcBn44Y/pJ/bzu/F17XAcmABcAfwjucApDSdCvwnYdajP+7nxr83
44CvAT8nTJk+yACQ0nEY8I/Ao8DEAtv5AHAb8F+EiVQNAKlikwmzIV1A73MjtsoE4AnCLz/bDACp
GpfH3f3DK2h7CHAz4SThMANAKtdNhOcKtFfcjz8FfsC+X2EwAKQBuhG4JqH+TAEeBPY3AKRizSJc
mkvNacDdKW13BoDqZiJwa8L9OxOYbQBIrTccuAcYmng/vwT8vgEgtdZNwBEZ9LMd+IcUgsoAUF2c
BFyaUX/HEu4RMACkFphD9Zf7+upqKr40aACoDsYR7uvPzXDgMgNAGpjLSfh22158qsrt0ABQ7vYn
3GmXqw8QfppsAEj98EckeIttH51nAEj9M70GyzDNAJD6Z0oNluEIwjMIDQCpDw4AxtRkWcYZAFLf
HEO+Z/97OtYAkPrm6Boty1EGgNQ3B9VoWToMAKm5AXCgASDJAJD20a9qtCxbDACpuQGw2QCQ+ubF
Gi3Lzw0AqW/+B+isybKsNACkvtkKrKrJsiw3AKS+W1qDZVgDvGQASH23qAbLsLiqhg0A5e4HVHQG
vYXuNgCk/nkLuD/j/r8CLDEApP6bT75XA/4e2GUASP23HPh+hv3eCNxWZQcMANXF9cDOzPo8j4rP
XxgAqov/Bm7PqL8rgFuq7oQBoDq5lnBNPXU7gb8C3jEApNYeU388hQ2rFzcA/5FCRwwA1c0y4MqE
+/cQMDeVzhgAqqMFhKnCU/MoYRKQXQaAVKzrgL9OqD9LgbMINy5hAEjFuxa4guovD95PmMIsuVuW
DQDV3XzC1FvrKmh7O3AV8DHg1ykWxwBQEzwGjAf+ifJuGX4aOIVwrT/Z25QNADXFeuDPgdMIVwqK
8gpwGfAR4KnUi2IAqGkeB343HhZ8P+6mt8Jy4BLCDD8LSOhM/3sZ7PqghloSXyOAc4EZwGT2fYae
HcCThLP79wHP5VgEA0BN9zrhROF8oJ0w4ehYwnTd7yPM2DOEcAZ/E+FW45WEe/m35r7wBoC0x864
Ya9oygJ7DkBqMANAMgAkGQCSDABJBoAkA0BSXRVxH8Akws0V2mNsj/cnA/tZlm5O2EvNpluWbkbk
EAB3OU69usUS9OqK+JKHAJIMAEnJHwLMBt60tN1MBs7v8v5vgRctSzdHA5/r8v6fCQ/y0B6HktAT
hXebS3jiye7XKMfpXWb1qNEkS/Iuk3rUaJYleZdRPWo04DDwEEDyHIAkA0CSASDJAJBkAEgyACTV
Tp0eCnowcCzhWukhwAGERzdvATYQbrx5kUSnaCrJAcAY4IOxRgfGf99do9XAKmrwtNsBGEa4Keno
LjUaHGuyAVhLeCrwJgOgWsOBMwi/GDuNfbsBaSfwU8Lz4P8t/nd7jVfm3wBOJ0yCMSVu/G29/E1n
DIGlwGLgYeDtGtdoCDAV+MP43xMIjwfvzVrCdN+LgIXAxqYmZtl3Ak4hTMSwrUe7/Xn9EriZ8Az4
IpV9J+B44FtxpRxojTbGzxpfcJ/LvhNwdBz7X7agRtviOjml4D63/E7AnAJgOvDjFgzW3l7bCRNH
js48AMYD3yNMS9XqGu2Knz0+8wAYHcd6e0Hr0o8p7jkGjQyAw2O6dpbwegu4Ph4H5hQAwwkz2+ws
oUY7Y1vDMwuAYXFs3yppXbovrrsGwACcDbxR0oB1fT1DmCIqhwCYSJiuquwarYlt5xAAx8QxLbtG
b8R1ONkASPUyYBswD3iA8BPIso0jTO18euLnXy4lzHZbxS8wR8W2L028RqfHsRxXQduHxnV4Hr2f
fK1EigEwBLgT+HzFResAvgtcmOiK/SXCNNRDKh6rBbEvKbowjmFHhX1oi+vynRWP1V6ldhmwDfg2
MDOh+nwr7m7dmVCd5gJXJ9Sf64GhhIfBpOIv4til8s17QfzCvSCuT+4B7MVXE9r4u4bSN4EzE+nP
rMQ2/t2uJp2HeJwZxyy13e6ZcR33EOD/SezLEt2VbCdcOjqm4n5MJpyBT9X82McqHRPHqj3RGl0W
13UDoIvjgdtIWwdwL62/RLivDiM8J6894Rq1xz4eVlH7w+IYdSS+Lt0W13kDIO6mfR3Yn/SNJ5zQ
qcJXgJEZ1Ghk7GsVPk/xdyy2wv5xnW8zAOAi4BTyMRs4soJd/09kVKNPVHAocCRpnYTszSlx3W90
AAwDbiQv+wFzSm4z2evI77FXN6/kNueQ33RrN1Z4SJlEAFyUyW5tTx+n+B8Q7TaN1t5xV5aJse9l
GB3HJDcjq94LqDoAriRPg4FPW6Nk+v5p8v1p+5VNDYDfITzAI1czS1jpRgAfzbhGH6WAGW33EsYz
M67RsXFbaFwAnE/eRgAzCm7jXPJ+aMvguAxFmlFCyNR2W6gyAGaQv+nWqPJlmG6N8guAkcBxNRi4
Ik9ytQOn1qBGp1LszUvTalCj46joZHhVAXAS9XAC4UcwRRhDeNBp7g6Oy1KEoXEM6uCkJgXAcTUZ
tPYCV+4x1EeRNWqvSY2Oa1IAHFWjlfvozD7XGqXpqCYFwPAaDVxRu+mH1qhGh2ZW+8ZsE1UFwIE1
GriDrFFly3KQNcozAOo0I9Fga2SNcl2WqgJgS40G7lcFfe7mGtVoc2a1b8w2MahmG02dBs4A8Iuk
tgGwukYD93JBn7u2RjVam1ntG7NNVBUAq2o0cCsL+twVNarRisxq35htoqoAeLYmg7aG4qaJXkE9
Zi7eXmAAbIpjUAfPNikAXgBer8GgPVbgZ28FflKDGv0kLkuOY1CW1+M20ZgA6ASW1GDgFhX8+XWo
0eLMx6AMS6hospAqfw78L5kP2jZgYcFt3FeDlbvocV4Yx8IaZRYA3wPezHjQFgIbC27jOWB5xjVa
HpehSBtLCOIivRm3hcYFwDvANzIeuNtLamdBxjVaULOxKMI34rbQuAAAuAV4O8NBe6LE4/M7gHUZ
1mhd7HtZx9BPZFijt+M2QFMD4BeZfsN9seQ9pZsyrNFNJX+zfTHDGi2I20BjAwDC3PI5fcPdBzxS
cptfB57OqEZPxz6X6RHyOrG8Lq77ND0ANpPPs+/fBK6qoN2dwCVVHiv2cY/lktjnsn2GfE4sX0kC
v/dIZXbg+zM4FOgkzOJS1d7K08AXMlixv1Dh3sq6OEadiddoQVznMQD2uAp4MuFBmwc8VHEfbgYe
SLhGD8Q+Vukhyp+XsC+erGgvMvkA2AacQZo/FLoLuCaRvZDzSPMOwSWxbyl8+14Txyw1q+I6vs0A
2Lv1hOe8p/RLuHuAixParXwHOIe07oF/LPYplXMUnXHM7kmoRiviur0+pQ0utQAAeAWYlMjhwHzC
vHOpnXzbRJh378EE+vJg7MumxGr0Thy7+Yns9k+K6zYGQO/eAE6juju83iKcTLoC2JVojbbFb93r
qeaM+87Y9jmkey/+rjiGF8UxrcLtcV1+I8UCpRoAu1fwWXEFe63Edp8APkJ5d7ENdAW/Ma5gZR42
rYht3phwQHZ1RxzTMu8WfC2uu7MSDsikA2C3fyVMofyVggu5Dvhk3FV7nrz8CDgRmA1sKLCdDbGN
E2ObOXk+ju0nKfZS7ra4rh4b193am0s46bL7NarAtt4P/E3cneps0esFwo0rwwrs96webU4qsK2O
uJG+0sIavRI/s6PAfk/q0easAtsaFsf8hRbW6I24br6/wH6P6tHm3KYFQNcBPBu4m3AvdV8H62fA
rcDEkmpUZgB03bubDnyT8MDJvtZodfzb6SXtKZYZAF1NjOvCz/pRo1/EdfDsgr9ACguAXCdW+DXw
3fhqA44HPhR3u44gzLJyCOF5dFsIl15eJjxE8qmSzylUeX5gEXuemDMaGE+YUPNIwlRUu7/RNxN+
V/8y4Vr1M8BLNMOy+AL4LeDkuB4dCRwW16Uh8fBnC+EZhCsJzznYHRrZqsPMKp3x+O559F5eatBG
3V+vER4usrApCzzIMZeaywCQDABJBoAkA0CSASDJAJBUV0XcBzCaetxf0Eq/2eP9yFgnda9Jz5pZ
o+5G5RAASx2nXt1rCXo1J76U+CHALssoVWJXCgGwlMzvh5Yy1NmKve22FnVmBuF5Z5LKsZjyJ6iR
JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEkl+D+B9JFB0vqGWgAAAABJRU5ErkJggg==
""")
sha1 = hashlib.sha1()
sha1.update(TEST_IMAGE)
TEST_IMAGE_SHA1 = sha1.hexdigest()
del sha1
# jids used in the tests
TEST_FROM = aioxmpp.structs.JID.fromstr("foo@bar.example/baz")
TEST_FROM_OTHER = aioxmpp.structs.JID.fromstr("foo@bar.example/quux")
TEST_JID1 = aioxmpp.structs.JID.fromstr("bar@bar.example/baz")
TEST_JID2 = aioxmpp.structs.JID.fromstr("baz@bar.example/baz")
TEST_JID3 = aioxmpp.structs.JID.fromstr("baz@bar.example/quux")
class TestAvatarSet(unittest.TestCase):
def test_construction(self):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
aset.add_avatar_image("image/png",
nbytes=0,
id_="0000000000",
url="http://example.com/avatar")
self.assertEqual(
aset.image_bytes,
TEST_IMAGE
)
self.assertEqual(
aset.png_id,
TEST_IMAGE_SHA1,
)
self.assertEqual(
aset.metadata.info["image/png"][0].nbytes,
len(TEST_IMAGE)
)
self.assertEqual(
aset.metadata.info["image/png"][0].id_,
TEST_IMAGE_SHA1,
)
self.assertEqual(
aset.metadata.info["image/png"][1].nbytes,
0,
)
self.assertEqual(
aset.metadata.info["image/png"][1].id_,
"0000000000",
)
self.assertEqual(
aset.metadata.info["image/png"][1].url,
"http://example.com/avatar",
)
def test_correct_redundant_information_is_ignored(self):
try:
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
image_bytes=TEST_IMAGE,
nbytes=len(TEST_IMAGE),
id_=TEST_IMAGE_SHA1)
except RuntimeError:
self.fail("raises on correct redundant information")
def test_png_id_is_normalized(self):
for transmuted_sha1 in (TEST_IMAGE_SHA1.lower(),
TEST_IMAGE_SHA1.upper()):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
image_bytes=TEST_IMAGE,
id_=transmuted_sha1)
self.assertEqual(
aset.metadata.info["image/png"][0].id_,
avatar_service.normalize_id(TEST_IMAGE_SHA1),
)
def test_error_id_missing(self):
with self.assertRaisesRegex(
RuntimeError,
"^The SHA1 of the image data is not given an not inferable "):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
nbytes=1024,
url="http://example.com/avatar")
def test_error_nbytes_missing(self):
with self.assertRaisesRegex(
RuntimeError,
"^Image data length is not given an not inferable "):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
id_="00000000000000000000",
url="http://example.com/avatar")
def test_error_no_image_given(self):
with self.assertRaisesRegex(
RuntimeError,
"^Either the image bytes or an url to retrieve the avatar "):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
id_="00000000000000000000",
nbytes=0)
def test_error_image_data_for_something_other_than_png(self):
with self.assertRaisesRegex(
RuntimeError,
"^The image bytes can only be given for image/png data\.$"):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/gif",
nbytes=1024,
id_="00000000000000000000",
image_bytes=TEST_IMAGE)
def test_error_two_items_with_image_data(self):
with self.assertRaisesRegex(
RuntimeError,
"^Only one avatar image may be published directly\.$"):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
image_bytes=TEST_IMAGE)
aset.add_avatar_image("image/png",
image_bytes=TEST_IMAGE)
def test_error_redundant_sha_mismatch(self):
with self.assertRaisesRegex(
RuntimeError,
"^The given id does not match the SHA1 of the image data\.$"):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
id_="00000000000000000000",
image_bytes=TEST_IMAGE)
def test_error_redundant_nbytes_mismatch(self):
with self.assertRaisesRegex(
RuntimeError,
"^The given length does not match the length "
"of the image data\.$"):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png",
nbytes=0,
image_bytes=TEST_IMAGE)
def test_error_no_image(self):
with self.assertRaisesRegex(
RuntimeError,
"^Either the image bytes or an url to retrieve the avatar "):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png")
class TestAvatarService(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.cc.local_jid = TEST_FROM
self.disco_client = aioxmpp.DiscoClient(self.cc)
self.disco = aioxmpp.DiscoServer(self.cc)
self.presence_dispatcher = aioxmpp.dispatcher.SimplePresenceDispatcher(
self.cc
)
self.presence_client = aioxmpp.PresenceClient(self.cc, dependencies={
aioxmpp.dispatcher.SimplePresenceDispatcher:
self.presence_dispatcher,
})
self.presence_server = aioxmpp.PresenceServer(self.cc)
self.vcard = aioxmpp.vcard.VCardService(self.cc)
self.pubsub = aioxmpp.PubSubClient(self.cc, dependencies={
aioxmpp.DiscoClient: self.disco_client
})
self.pep = aioxmpp.PEPClient(self.cc, dependencies={
aioxmpp.DiscoClient: self.disco_client,
aioxmpp.DiscoServer: self.disco,
aioxmpp.PubSubClient: self.pubsub,
})
self.s = avatar_service.AvatarService(self.cc, dependencies={
aioxmpp.DiscoClient: self.disco_client,
aioxmpp.DiscoServer: self.disco,
aioxmpp.PubSubClient: self.pubsub,
aioxmpp.PEPClient: self.pep,
aioxmpp.PresenceClient: self.presence_client,
aioxmpp.PresenceServer: self.presence_server,
aioxmpp.vcard.VCardService: self.vcard
})
self.pep._check_for_pep = CoroutineMock()
self.pep.available = CoroutineMock()
self.pep.available.return_value = True
self.cc.mock_calls.clear()
def tearDown(self):
del self.s
del self.cc
del self.disco_client
del self.disco
del self.pubsub
def test_is_service(self):
self.assertTrue(issubclass(
avatar_service.AvatarService,
aioxmpp.service.Service
))
def test_service_order(self):
self.assertIn(
aioxmpp.DiscoClient,
avatar_service.AvatarService.ORDER_AFTER,
)
self.assertIn(
aioxmpp.DiscoServer,
avatar_service.AvatarService.ORDER_AFTER,
)
self.assertIn(
aioxmpp.PubSubClient,
avatar_service.AvatarService.ORDER_AFTER,
)
def test_metadata_cache_size(self):
self.assertEqual(self.s.metadata_cache_size, 200)
self.s.metadata_cache_size = 100
self.assertEqual(self.s.metadata_cache_size, 100)
def test_handle_stream_destroyed_is_depsignal_handler(self):
self.assertTrue(aioxmpp.service.is_depsignal_handler(
aioxmpp.stream.StanzaStream,
"on_stream_destroyed",
self.s.handle_stream_destroyed
))
def test_handle_stream_destroyer(self):
# for now just check the code can be run without errors,
# it is difficult to check this properly, as we do not
# know which caches should be wiped a priori
self.s.handle_stream_destroyed(unittest.mock.Mock())
def test_attach_vcard_notify_to_presence_is_depfilter(self):
self.assertTrue(aioxmpp.service.is_depfilter_handler(
aioxmpp.stream.StanzaStream,
"service_outbound_presence_filter",
self.s._attach_vcard_notify_to_presence
))
def test_attach_vcard_notify_to_presence(self):
stanza = aioxmpp.Presence()
stanza = self.s._attach_vcard_notify_to_presence(stanza)
self.assertIsInstance(stanza.xep0153_x,
avatar_xso.VCardTempUpdate)
self.assertIsNone(stanza.xep0153_x.photo)
vcard_id = '1234'
self.s._vcard_id = vcard_id
stanza = aioxmpp.Presence()
stanza = self.s._attach_vcard_notify_to_presence(stanza)
self.assertIsInstance(stanza.xep0153_x,
avatar_xso.VCardTempUpdate)
self.assertEqual(stanza.xep0153_x.photo, vcard_id)
self.s._vcard_resource_interference.add(TEST_JID1)
stanza = aioxmpp.Presence()
stanza = self.s._attach_vcard_notify_to_presence(stanza)
self.assertIsNotNone(stanza.xep0153_x)
self.assertIsNone(stanza.xep0153_x.photo)
def test_handle_on_available_is_depsignal_handler(self):
self.assertTrue(aioxmpp.service.is_depsignal_handler(
aioxmpp.PresenceClient,
"on_available",
self.s._handle_on_available
))
def test_resource_interference(self):
stanza = aioxmpp.Presence()
self.s._handle_on_available(TEST_FROM_OTHER, stanza)
self.assertCountEqual(
self.s._vcard_resource_interference,
[TEST_FROM_OTHER]
)
stanza = aioxmpp.Presence()
self.s._handle_on_unavailable(TEST_FROM_OTHER, stanza)
self.assertCountEqual(
self.s._vcard_resource_interference,
[]
)
def test_trigger_rehash(self):
mock_handler = unittest.mock.Mock()
self.s.on_metadata_changed.connect(mock_handler)
stanza = aioxmpp.Presence()
stanza.xep0153_x = avatar_xso.VCardTempUpdate("1234")
self.s._handle_on_available(TEST_FROM_OTHER, stanza)
first_rehash_task = self.s._vcard_rehash_task
self.assertIsNot(first_rehash_task, None)
# presence with the same hash does not affect the rehash task
self.s._vcard_id = "1234"
stanza = aioxmpp.Presence()
stanza.xep0153_x = avatar_xso.VCardTempUpdate("1234")
self.s._handle_on_available(TEST_FROM_OTHER, stanza)
self.assertIs(self.s._vcard_rehash_task, first_rehash_task)
# presence with another hash cancels the task
stanza = aioxmpp.Presence()
stanza.xep0153_x = avatar_xso.VCardTempUpdate("4321")
self.s._handle_on_available(TEST_FROM_OTHER, stanza)
with unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock()):
vcard = vcard_xso.VCard()
self.vcard.get_vcard.return_value = vcard
vcard.set_photo_data("image/png", TEST_IMAGE)
loop = asyncio.get_event_loop()
with self.assertRaises(asyncio.CancelledError):
loop.run_until_complete(first_rehash_task)
self.assertTrue(first_rehash_task.cancelled())
loop.run_until_complete(
self.s._vcard_rehash_task
)
self.assertEqual(self.s._vcard_id, TEST_IMAGE_SHA1)
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock())
)
resend_presence = stack.enter_context(
unittest.mock.patch.object(
self.presence_server,
"resend_presence",
)
)
vcard = vcard_xso.VCard()
self.vcard.get_vcard.return_value = vcard
stanza = aioxmpp.Presence()
stanza.xep0153_x = avatar_xso.VCardTempUpdate("9132752")
self.s._handle_on_available(TEST_FROM_OTHER, stanza)
loop.run_until_complete(
self.s._vcard_rehash_task
)
resend_presence.assert_called_once_with()
self.assertEqual(self.s._vcard_id, "")
# XXX: should we test minutely that we get the right metadata
mock_handler.assert_called_with(TEST_FROM_OTHER.bare(),
unittest.mock.ANY)
def test_handle_on_changed_is_depsignal_handler(self):
self.assertTrue(aioxmpp.service.is_depsignal_handler(
aioxmpp.PresenceClient,
"on_changed",
self.s._handle_on_changed
))
def test_handle_notify_without_photo_is_noop(self):
mock_handler = unittest.mock.Mock()
self.s.on_metadata_changed.connect(mock_handler)
stanza = aioxmpp.Presence()
self.s._handle_notify(TEST_JID1, stanza)
stanza.xep0153_x = avatar_xso.VCardTempUpdate()
self.s._handle_notify(TEST_JID1, stanza)
self.assertEqual(len(mock_handler.mock_calls), 0)
def test_handle_on_changed(self):
with unittest.mock.patch.object(self.s, "_handle_notify"):
self.s._handle_on_changed(unittest.mock.sentinel.jid,
unittest.mock.sentinel.staza)
self.assertSequenceEqual(
self.s._handle_notify.mock_calls,
[
unittest.mock.call(unittest.mock.sentinel.jid,
unittest.mock.sentinel.staza)
]
)
def test_handle_on_unavailable_is_depsignal_handler(self):
self.assertTrue(aioxmpp.service.is_depsignal_handler(
aioxmpp.PresenceClient,
"on_unavailable",
self.s._handle_on_unavailable
))
def test_publish_avatar_set(self):
# set the cache to indicate the server has PEP
avatar_set = avatar_service.AvatarSet()
avatar_set.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
with unittest.mock.patch.object(self.pep, "publish",
new=CoroutineMock()):
run_coroutine(self.s.publish_avatar_set(avatar_set))
self.assertSequenceEqual(
self.pep.publish.mock_calls,
[
unittest.mock.call(
namespaces.xep0084_data,
unittest.mock.ANY,
id_=avatar_set.png_id
),
unittest.mock.call(
namespaces.xep0084_metadata,
avatar_set.metadata,
id_=avatar_set.png_id
)
],
)
_, args, _ = self.pep.publish.mock_calls[0]
data = args[1]
self.assertTrue(isinstance(data, avatar_xso.Data))
self.assertEqual(data.data, avatar_set.image_bytes)
def test_publish_avatar_no_protocol_raises(self):
self.pep.available.return_value = False
self.s.synchronize_vcard = False
with self.assertRaises(RuntimeError):
run_coroutine(self.s.publish_avatar_set(unittest.mock.Mock()))
def test_publish_avatar_set_synchronize_vcard(self):
avatar_set = avatar_service.AvatarSet()
avatar_set.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
self.assertFalse(self.s.synchronize_vcard)
self.s.synchronize_vcard = True
self.assertTrue(self.s.synchronize_vcard)
with contextlib.ExitStack() as e:
e.enter_context(unittest.mock.patch.object(self.pep, "publish",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.presence_server,
"resend_presence"))
e.enter_context(unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.vcard, "set_vcard",
new=CoroutineMock()))
self.vcard.get_vcard.return_value = unittest.mock.Mock()
run_coroutine(self.s.publish_avatar_set(avatar_set))
self.assertSequenceEqual(
self.presence_server.resend_presence.mock_calls,
[unittest.mock.call()]
)
self.assertSequenceEqual(
self.vcard.get_vcard.mock_calls,
[
unittest.mock.call(),
unittest.mock.call().set_photo_data("image/png",
TEST_IMAGE),
]
)
self.assertSequenceEqual(
self.vcard.set_vcard.mock_calls,
[
unittest.mock.call(self.vcard.get_vcard.return_value),
]
)
self.assertSequenceEqual(
self.pep.publish.mock_calls,
[
unittest.mock.call(
namespaces.xep0084_data,
unittest.mock.ANY,
id_=avatar_set.png_id
),
unittest.mock.call(
namespaces.xep0084_metadata,
avatar_set.metadata,
id_=avatar_set.png_id
)
],
)
_, args, _ = self.pep.publish.mock_calls[0]
data = args[1]
self.assertTrue(isinstance(data, avatar_xso.Data))
self.assertEqual(data.data, avatar_set.image_bytes)
def test_publish_avatar_set_synchronize_vcard_pep_raises(self):
avatar_set = avatar_service.AvatarSet()
avatar_set.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
self.s.synchronize_vcard = True
with contextlib.ExitStack() as e:
e.enter_context(unittest.mock.patch.object(self.pep, "publish",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.presence_server,
"resend_presence"))
e.enter_context(unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.vcard, "set_vcard",
new=CoroutineMock()))
# do not do the vcard operations of pep is available but
# fails
self.pep.publish.side_effect = RuntimeError
self.vcard.get_vcard.return_value = unittest.mock.Mock()
with self.assertRaises(RuntimeError):
run_coroutine(self.s.publish_avatar_set(avatar_set))
self.assertSequenceEqual(
self.presence_server.resend_presence.mock_calls,
[]
)
self.assertSequenceEqual(
self.vcard.get_vcard.mock_calls,
[]
)
self.assertSequenceEqual(
self.vcard.set_vcard.mock_calls,
[]
)
def test_disable_avatar(self):
with unittest.mock.patch.object(self.pep, "publish",
new=CoroutineMock()):
run_coroutine(self.s.disable_avatar())
self.assertSequenceEqual(
self.pep.publish.mock_calls,
[
unittest.mock.call(
namespaces.xep0084_metadata,
unittest.mock.ANY,
),
]
)
_, args, _ = self.pep.publish.mock_calls[0]
metadata = args[1]
self.assertTrue(isinstance(metadata, avatar_xso.Metadata))
self.assertEqual(0, len(metadata.info))
self.assertEqual(0, len(metadata.pointer))
def test_wipe_avatar(self):
with unittest.mock.patch.object(self.pep, "publish",
new=CoroutineMock()):
run_coroutine(self.s.wipe_avatar())
self.assertSequenceEqual(
self.pep.publish.mock_calls,
[
unittest.mock.call(
namespaces.xep0084_metadata,
unittest.mock.ANY,
),
unittest.mock.call(
namespaces.xep0084_data,
unittest.mock.ANY,
),
]
)
_, args, _ = self.pep.publish.mock_calls[0]
metadata = args[1]
self.assertTrue(isinstance(metadata, avatar_xso.Metadata))
self.assertEqual(0, len(metadata.info))
self.assertEqual(0, len(metadata.pointer))
_, args, _ = self.pep.publish.mock_calls[1]
data = args[1]
self.assertTrue(isinstance(data, avatar_xso.Data))
self.assertEqual(0, len(data.data))
def test_wipe_avatar_with_vcard(self):
self.s.synchronize_vcard = True
with contextlib.ExitStack() as e:
e.enter_context(unittest.mock.patch.object(self.pep, "publish",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.presence_server,
"resend_presence"))
e.enter_context(unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.vcard, "set_vcard",
new=CoroutineMock()))
self.vcard.get_vcard.return_value = unittest.mock.Mock()
run_coroutine(self.s.wipe_avatar())
self.assertSequenceEqual(
self.presence_server.resend_presence.mock_calls,
[unittest.mock.call()]
)
self.assertSequenceEqual(
self.vcard.get_vcard.mock_calls,
[unittest.mock.call(),
unittest.mock.call().clear_photo_data()]
)
self.assertSequenceEqual(
self.vcard.set_vcard.mock_calls,
[unittest.mock.call(unittest.mock.ANY)]
)
self.assertSequenceEqual(
self.pep.publish.mock_calls,
[
unittest.mock.call(
namespaces.xep0084_metadata,
unittest.mock.ANY,
),
unittest.mock.call(
namespaces.xep0084_data,
unittest.mock.ANY,
),
]
)
_, args, _ = self.pep.publish.mock_calls[0]
metadata = args[1]
self.assertTrue(isinstance(metadata, avatar_xso.Metadata))
self.assertEqual(0, len(metadata.info))
self.assertEqual(0, len(metadata.pointer))
_, args, _ = self.pep.publish.mock_calls[1]
data = args[1]
self.assertTrue(isinstance(data, avatar_xso.Data))
self.assertEqual(0, len(data.data))
def test_disable_avatar_synchronize_vcard_pep_raises(self):
self.s.synchronize_vcard = True
with contextlib.ExitStack() as e:
e.enter_context(unittest.mock.patch.object(self.pep, "publish",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.presence_server,
"resend_presence"))
e.enter_context(unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(self.vcard, "set_vcard",
new=CoroutineMock()))
# do not do the vcard operations of pep is available but
# fails
self.pep.publish.side_effect = RuntimeError
self.vcard.get_vcard.return_value = unittest.mock.Mock()
with self.assertRaises(RuntimeError):
run_coroutine(self.s.disable_avatar())
self.assertSequenceEqual(
self.presence_server.resend_presence.mock_calls,
[unittest.mock.call()]
)
self.assertSequenceEqual(
self.vcard.get_vcard.mock_calls,
[unittest.mock.call(),
unittest.mock.call().clear_photo_data()]
)
self.assertSequenceEqual(
self.vcard.set_vcard.mock_calls,
[unittest.mock.call(unittest.mock.ANY)]
)
def test_handle_pubsub_publish(self):
self.assertTrue(aioxmpp.service.is_attrsignal_handler(
avatar_service.AvatarService.avatar_pep,
"on_item_publish",
self.s._handle_pubsub_publish
))
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
aset.add_avatar_image("image/png",
nbytes=0,
id_="00000000000000000000",
url="http://example.com/avatar")
# construct the proper pubsub response
item = pubsub_xso.EventItem(aset.metadata, id_=aset.png_id)
mock_handler = unittest.mock.Mock()
self.s.on_metadata_changed.connect(mock_handler)
self.s._handle_pubsub_publish(
TEST_JID1,
namespaces.xep0084_metadata,
item)
descriptors = self.s._metadata_cache[TEST_JID1]
self.assertEqual(len(descriptors), 2)
png_descr = descriptors
self.assertTrue(isinstance(png_descr[0],
avatar_service.PubsubAvatarDescriptor))
self.assertEqual(png_descr[0].mime_type, "image/png")
self.assertEqual(png_descr[0].id_, TEST_IMAGE_SHA1)
self.assertEqual(png_descr[0].nbytes, len(TEST_IMAGE))
self.assertEqual(png_descr[0].url, None)
self.assertTrue(isinstance(png_descr[0],
avatar_service.PubsubAvatarDescriptor))
self.assertEqual(png_descr[1].mime_type, "image/png")
self.assertEqual(png_descr[1].id_, "00000000000000000000")
self.assertEqual(png_descr[1].nbytes, 0)
self.assertEqual(png_descr[1].url, "http://example.com/avatar")
mock_handler.assert_called_with(TEST_JID1, descriptors)
def test_get_avatar_metadata(self):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
aset.add_avatar_image("image/png",
nbytes=0,
id_="00000000000000000000",
url="http://example.com/avatar")
# construct the proper pubsub response
items = pubsub_xso.Items(
namespaces.xep0084_metadata,
)
item = pubsub_xso.Item(id_=aset.png_id)
item.registered_payload = aset.metadata
items.items.append(item)
pubsub_result = pubsub_xso.Request(items)
with unittest.mock.patch.object(self.pubsub, "get_items",
new=CoroutineMock()):
self.pubsub.get_items.return_value = pubsub_result
descriptors = run_coroutine(self.s.get_avatar_metadata(TEST_JID1))
self.assertSequenceEqual(
self.pubsub.get_items.mock_calls,
[
unittest.mock.call(
TEST_JID1,
namespaces.xep0084_metadata,
max_items=1
)
]
)
self.assertEqual(len(descriptors), 2)
png_descr = descriptors
self.assertTrue(isinstance(png_descr[0],
avatar_service.PubsubAvatarDescriptor))
self.assertEqual(png_descr[0].mime_type, "image/png")
self.assertEqual(png_descr[0].id_, TEST_IMAGE_SHA1)
self.assertEqual(png_descr[0].nbytes, len(TEST_IMAGE))
self.assertEqual(png_descr[0].url, None)
self.assertTrue(isinstance(png_descr[0],
avatar_service.PubsubAvatarDescriptor))
self.assertEqual(png_descr[1].mime_type, "image/png")
self.assertEqual(png_descr[1].id_, "00000000000000000000")
self.assertEqual(png_descr[1].nbytes, 0)
self.assertEqual(png_descr[1].url, "http://example.com/avatar")
with unittest.mock.patch.object(self.pubsub, "get_items",
new=CoroutineMock()):
cached_descriptors = run_coroutine(
self.s.get_avatar_metadata(TEST_JID1)
)
self.assertEqual(descriptors, cached_descriptors)
# we must get the descriptors from the cache
self.pubsub.get_items.assert_not_called()
with unittest.mock.patch.object(self.pubsub, "get_items",
new=CoroutineMock()):
self.pubsub.get_items.return_value = pubsub_result
descriptors = run_coroutine(
self.s.get_avatar_metadata(TEST_JID1, require_fresh=True)
)
self.assertSequenceEqual(
self.pubsub.get_items.mock_calls,
[
unittest.mock.call(
TEST_JID1,
namespaces.xep0084_metadata,
max_items=1
)
]
)
def test_get_avatar_metadata_with_require_fresh_does_not_crash(self):
aset = avatar_service.AvatarSet()
aset.add_avatar_image("image/png", image_bytes=TEST_IMAGE)
aset.add_avatar_image("image/png",
nbytes=0,
id_="00000000000000000000",
url="http://example.com/avatar")
# construct the proper pubsub response
items = pubsub_xso.Items(
namespaces.xep0084_metadata,
)
item = pubsub_xso.Item(id_=aset.png_id)
item.registered_payload = aset.metadata
items.items.append(item)
pubsub_result = pubsub_xso.Request(items)
with unittest.mock.patch.object(self.pubsub, "get_items",
new=CoroutineMock()):
self.pubsub.get_items.return_value = pubsub_result
run_coroutine(
self.s.get_avatar_metadata(TEST_JID1, require_fresh=True)
)
self.assertSequenceEqual(
self.pubsub.get_items.mock_calls,
[
unittest.mock.call(
TEST_JID1,
namespaces.xep0084_metadata,
max_items=1
)
]
)
def test_get_avatar_metadata_vcard_fallback(self):
sha1 = hashlib.sha1(b'')
empty_sha1 = sha1.hexdigest()
with contextlib.ExitStack() as e:
e.enter_context(unittest.mock.patch.object(
self.pubsub, "get_items",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(
self.vcard, "get_vcard",
new=CoroutineMock()))
vcard_mock = unittest.mock.Mock()
vcard_mock.get_photo_data.return_value = b''
self.vcard.get_vcard.return_value = vcard_mock
self.pubsub.get_items.side_effect = errors.XMPPCancelError(
errors.ErrorCondition.FEATURE_NOT_IMPLEMENTED
)
res = run_coroutine(self.s.get_avatar_metadata(TEST_JID1))
self.assertEqual(len(res), 1)
self.assertIsInstance(res[0],
avatar_service.VCardAvatarDescriptor)
self.assertEqual(res[0]._image_bytes,
b'')
self.assertEqual(res[0].id_,
empty_sha1)
self.assertEqual(res[0].nbytes,
0)
self.assertSequenceEqual(
self.pubsub.get_items.mock_calls,
[
unittest.mock.call(
TEST_JID1,
namespaces.xep0084_metadata,
max_items=1
),
]
)
with contextlib.ExitStack() as e:
e.enter_context(unittest.mock.patch.object(
self.pubsub, "get_items",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(
self.vcard, "get_vcard",
new=CoroutineMock()))
vcard_mock = unittest.mock.Mock()
vcard_mock.get_photo_data.return_value = b''
self.vcard.get_vcard.return_value = vcard_mock
self.pubsub.get_items.side_effect = errors.XMPPCancelError(
errors.ErrorCondition.ITEM_NOT_FOUND
)
res = run_coroutine(self.s.get_avatar_metadata(TEST_JID2))
self.assertEqual(len(res), 1)
self.assertIsInstance(res[0],
avatar_service.VCardAvatarDescriptor)
self.assertEqual(res[0]._image_bytes,
b'')
self.assertEqual(res[0].id_,
empty_sha1)
self.assertEqual(res[0].nbytes,
0)
self.assertSequenceEqual(
self.pubsub.get_items.mock_calls,
[
unittest.mock.call(
TEST_JID2,
namespaces.xep0084_metadata,
max_items=1
),
]
)
with contextlib.ExitStack() as e:
e.enter_context(unittest.mock.patch.object(
self.pubsub, "get_items",
new=CoroutineMock()))
e.enter_context(unittest.mock.patch.object(
self.vcard, "get_vcard",
new=CoroutineMock()))
vcard_mock = unittest.mock.Mock()
vcard_mock.get_photo_data.return_value = None
self.vcard.get_vcard.return_value = vcard_mock
self.pubsub.get_items.side_effect = errors.XMPPCancelError(
errors.ErrorCondition.ITEM_NOT_FOUND
)
res = run_coroutine(self.s.get_avatar_metadata(TEST_JID3))
self.assertEqual(len(res), 0)
self.assertSequenceEqual(
self.pubsub.get_items.mock_calls,
[
unittest.mock.call(
TEST_JID3,
namespaces.xep0084_metadata,
max_items=1
),
]
)
def test_subscribe(self):
with unittest.mock.patch.object(self.pubsub, "subscribe",
new=CoroutineMock()):
run_coroutine(self.s.subscribe(TEST_JID1))
self.assertSequenceEqual(
self.pubsub.subscribe.mock_calls,
[
unittest.mock.call(
TEST_JID1,
namespaces.xep0084_metadata
),
]
)
class TestAvatarDescriptors(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.cc.local_jid = TEST_FROM
self.vcard = aioxmpp.vcard.VCardService(self.cc)
self.disco = aioxmpp.DiscoClient(self.cc)
self.pubsub = aioxmpp.PubSubClient(self.cc, dependencies={
aioxmpp.DiscoClient: self.disco
})
self.cc.mock_calls.clear()
def test_attributes_defined_by_AbstractAvatarDescriptor(self):
a = avatar_service.AbstractAvatarDescriptor(
TEST_JID1, TEST_IMAGE_SHA1, mime_type="image/png",
nbytes=len(TEST_IMAGE)
)
with self.assertRaises(NotImplementedError):
run_coroutine(a.get_image_bytes())
self.assertFalse(a.has_image_data_in_pubsub)
self.assertFalse(a.can_get_image_bytes_via_xmpp)
self.assertEqual(a.remote_jid, TEST_JID1)
self.assertEqual(a.mime_type, "image/png")
self.assertEqual(a.id_, TEST_IMAGE_SHA1)
self.assertEqual(a.nbytes, len(TEST_IMAGE))
self.assertEqual(a.normalized_id,
avatar_service.normalize_id(TEST_IMAGE_SHA1))
self.assertEqual(a.url, None)
self.assertEqual(a.width, None)
self.assertEqual(a.height, None)
def test_avatar_descriptor_equality(self):
vcard_descriptor = avatar_service.VCardAvatarDescriptor(
TEST_JID1,
TEST_IMAGE_SHA1.upper(),
nbytes=len(TEST_IMAGE),
vcard=self.vcard,
image_bytes=TEST_IMAGE,
)
pep_descriptor = avatar_service.PubsubAvatarDescriptor(
TEST_JID1,
TEST_IMAGE_SHA1.upper(),
mime_type="image/png",
nbytes=len(TEST_IMAGE),
pubsub=self.pubsub
)
pep2_descriptor = avatar_service.PubsubAvatarDescriptor(
TEST_JID2,
TEST_IMAGE_SHA1.upper(),
mime_type="image/png",
nbytes=len(TEST_IMAGE),
pubsub=self.pubsub
)
url_descriptor = avatar_service.HttpAvatarDescriptor(
TEST_JID1,
TEST_IMAGE_SHA1.upper(),
mime_type="image/png",
nbytes=len(TEST_IMAGE),
url="http://example.com/avatar"
)
self.assertEqual(pep_descriptor, pep_descriptor)
self.assertEqual(pep2_descriptor, pep2_descriptor)
self.assertEqual(url_descriptor, url_descriptor)
self.assertEqual(vcard_descriptor, vcard_descriptor)
self.assertNotEqual(pep_descriptor, pep2_descriptor)
self.assertNotEqual(pep_descriptor, url_descriptor)
self.assertNotEqual(pep_descriptor, vcard_descriptor)
self.assertNotEqual(url_descriptor, vcard_descriptor)
def test_vcard_get_image_bytes(self):
descriptor = avatar_service.VCardAvatarDescriptor(
TEST_JID1,
TEST_IMAGE_SHA1.upper(),
nbytes=len(TEST_IMAGE),
vcard=self.vcard,
image_bytes=TEST_IMAGE,
)
self.assertTrue(descriptor.has_image_data_in_pubsub)
self.assertTrue(descriptor.can_get_image_bytes_via_xmpp)
with unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock()):
self.assertEqual(
run_coroutine(descriptor.get_image_bytes()),
TEST_IMAGE
)
self.assertSequenceEqual(self.vcard.get_vcard.mock_calls, [])
descriptor = avatar_service.VCardAvatarDescriptor(
TEST_JID1,
TEST_IMAGE_SHA1.upper(),
nbytes=len(TEST_IMAGE),
vcard=self.vcard,
)
with unittest.mock.patch.object(self.vcard, "get_vcard",
new=CoroutineMock()):
vcard_mock = unittest.mock.Mock()
vcard_mock.get_photo_data.return_value = TEST_IMAGE
self.vcard.get_vcard.return_value = vcard_mock
self.assertEqual(
run_coroutine(descriptor.get_image_bytes()),
TEST_IMAGE
)
vcard_mock.get_photo_data.return_value = None
with self.assertRaises(RuntimeError):
run_coroutine(descriptor.get_image_bytes())
self.assertSequenceEqual(
self.vcard.get_vcard.mock_calls,
[
unittest.mock.call(
TEST_JID1
),
unittest.mock.call().get_photo_data(),
unittest.mock.call(
TEST_JID1
),
unittest.mock.call().get_photo_data(),
]
)
def test_pep_get_image_bytes(self):
descriptor = avatar_service.PubsubAvatarDescriptor(
TEST_JID1,
TEST_IMAGE_SHA1.upper(),
mime_type="image/png",
nbytes=len(TEST_IMAGE),
pubsub=self.pubsub
)
self.assertTrue(descriptor.has_image_data_in_pubsub)
self.assertEqual(TEST_IMAGE_SHA1, descriptor.normalized_id)
items = pubsub_xso.Items(
namespaces.xep0084_data,
)
pubsub_result = pubsub_xso.Request(items)
with unittest.mock.patch.object(self.pubsub, "get_items_by_id",
new=CoroutineMock()):
self.pubsub.get_items_by_id.return_value = pubsub_result
with self.assertRaises(RuntimeError):
res = run_coroutine(descriptor.get_image_bytes())
item = pubsub_xso.Item(id_=TEST_IMAGE_SHA1)
item.registered_payload = avatar_xso.Data(TEST_IMAGE)
items.items.append(item)
res = run_coroutine(descriptor.get_image_bytes())
self.assertSequenceEqual(
self.pubsub.get_items_by_id.mock_calls,
[
unittest.mock.call(
TEST_JID1,
namespaces.xep0084_data,
[TEST_IMAGE_SHA1.upper()],
),
unittest.mock.call(
TEST_JID1,
namespaces.xep0084_data,
[TEST_IMAGE_SHA1.upper()],
)
]
)
self.assertEqual(res, TEST_IMAGE)
def test_HttpAvatarDescriptor(self):
descriptor = avatar_service.HttpAvatarDescriptor(
TEST_JID1,
TEST_IMAGE_SHA1.upper(),
mime_type="image/png",
nbytes=len(TEST_IMAGE),
url="http://example.com/avatar"
)
self.assertFalse(descriptor.has_image_data_in_pubsub)
with self.assertRaises(NotImplementedError):
run_coroutine(descriptor.get_image_bytes())
tests/avatar/test_xso.py 0000664 0000000 0000000 00000033577 14160146213 0015735 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import contextlib
import unittest
import unittest.mock
import aioxmpp.xso as xso
import aioxmpp.avatar.xso as avatar_xso
from aioxmpp.utils import namespaces
class TestNamespaces(unittest.TestCase):
def test_data_namespace(self):
self.assertEqual(
"urn:xmpp:avatar:data",
namespaces.xep0084_data
)
def test_metadata_namespace(self):
self.assertEqual(
"urn:xmpp:avatar:metadata",
namespaces.xep0084_metadata
)
def test_xep0153_namespace(self):
self.assertEqual(
"vcard-temp:x:update",
namespaces.xep0153
)
class TestVCardTempUpdate(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(avatar_xso.VCardTempUpdate, xso.XSO))
def test_init(self):
vcard_update = avatar_xso.VCardTempUpdate()
self.assertEqual(vcard_update.photo, None)
vcard_update = avatar_xso.VCardTempUpdate("foobar")
self.assertEqual(vcard_update.photo, "foobar")
def test_tag(self):
self.assertEqual(
(namespaces.xep0153, "x"),
avatar_xso.VCardTempUpdate.TAG
)
def test_photo(self):
self.assertIsInstance(
avatar_xso.VCardTempUpdate.photo,
xso.ChildText
)
self.assertIsInstance(
avatar_xso.VCardTempUpdate.photo.type_,
xso.String
)
self.assertEqual(
avatar_xso.VCardTempUpdate.photo.tag,
(namespaces.xep0153, "photo")
)
class TestData(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(avatar_xso.Data, xso.XSO))
def test_init(self):
data = avatar_xso.Data(b"foo")
self.assertEqual(
data.data,
b"foo"
)
def test_tag(self):
self.assertEqual(
(namespaces.xep0084_data, "data"),
avatar_xso.Data.TAG
)
def test_data(self):
self.assertIsInstance(
avatar_xso.Data.data,
xso.Text
)
self.assertIsInstance(
avatar_xso.Data.data.type_,
xso.Base64Binary
)
class TestMetadata(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(avatar_xso.Metadata, xso.XSO))
def test_tag(self):
self.assertEqual(
(namespaces.xep0084_metadata, "metadata"),
avatar_xso.Metadata.TAG
)
def test_info(self):
self.assertIsInstance(
avatar_xso.Metadata.info,
xso.ChildMap
)
def test_pointer(self):
self.assertIsInstance(
avatar_xso.Metadata.pointer,
xso.ChildList
)
def test_iter_info_nodes(self):
info_list = [
avatar_xso.Info(id_="123",
mime_type="image/png",
nbytes=3),
avatar_xso.Info(id_="123",
mime_type="image/png",
nbytes=3,
width=10),
avatar_xso.Info(id_="345",
nbytes=4,
mime_type="image/gif",
url="http://example.com/avatar.gif"),
]
metadata = avatar_xso.Metadata()
for item in info_list:
metadata.info[item.mime_type].append(item)
self.assertCountEqual(
list(metadata.iter_info_nodes()),
info_list
)
class TestInfo(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(avatar_xso.Info, xso.XSO))
def test_init(self):
info = avatar_xso.Info(id_="123",
mime_type="image/png",
nbytes=3)
self.assertEqual(info.id_, "123")
self.assertEqual(info.mime_type, "image/png")
self.assertEqual(info.nbytes, 3)
self.assertEqual(info.width, None)
self.assertEqual(info.height, None)
self.assertEqual(info.url, None)
info = avatar_xso.Info(id_="123",
mime_type="image/png",
nbytes=3,
width=10)
self.assertEqual(info.id_, "123")
self.assertEqual(info.mime_type, "image/png")
self.assertEqual(info.nbytes, 3)
self.assertEqual(info.width, 10)
self.assertEqual(info.height, None)
self.assertEqual(info.url, None)
info = avatar_xso.Info(id_="123",
mime_type="image/png",
nbytes=3,
height=10)
self.assertEqual(info.id_, "123")
self.assertEqual(info.mime_type, "image/png")
self.assertEqual(info.nbytes, 3)
self.assertEqual(info.width, None)
self.assertEqual(info.height, 10)
self.assertEqual(info.url, None)
info = avatar_xso.Info(id_="123",
mime_type="image/png",
nbytes=3,
url="http://example.com/avatar")
self.assertEqual(info.id_, "123")
self.assertEqual(info.mime_type, "image/png")
self.assertEqual(info.nbytes, 3)
self.assertEqual(info.width, None)
self.assertEqual(info.height, None)
self.assertEqual(info.url, "http://example.com/avatar")
def test_tag(self):
self.assertEqual(
(namespaces.xep0084_metadata, "info"),
avatar_xso.Info.TAG
)
def test_nbytes(self):
self.assertIsInstance(
avatar_xso.Info.nbytes,
xso.Attr
)
self.assertEqual(
(None, "bytes"),
avatar_xso.Info.nbytes.tag
)
self.assertIsInstance(
avatar_xso.Info.nbytes.type_,
xso.Integer
)
self.assertIs(
avatar_xso.Info.nbytes.default,
xso.NO_DEFAULT
)
def test_height(self):
self.assertIsInstance(
avatar_xso.Info.height,
xso.Attr
)
self.assertEqual(
(None, "height"),
avatar_xso.Info.height.tag
)
self.assertIsInstance(
avatar_xso.Info.height.type_,
xso.Integer
)
self.assertIs(
avatar_xso.Info.height.default,
None
)
def test_id(self):
self.assertIsInstance(
avatar_xso.Info.id_,
xso.Attr
)
self.assertEqual(
(None, "id"),
avatar_xso.Info.id_.tag
)
self.assertIsInstance(
avatar_xso.Info.id_.type_,
xso.String
)
self.assertIs(
avatar_xso.Info.id_.default,
xso.NO_DEFAULT
)
def test_mime_type(self):
self.assertIsInstance(
avatar_xso.Info.mime_type,
xso.Attr
)
self.assertEqual(
(None, "type"),
avatar_xso.Info.mime_type.tag
)
self.assertIsInstance(
avatar_xso.Info.mime_type.type_,
xso.String
)
self.assertIs(
avatar_xso.Info.mime_type.default,
xso.NO_DEFAULT
)
def test_url(self):
self.assertIsInstance(
avatar_xso.Info.url,
xso.Attr
)
self.assertEqual(
(None, "url"),
avatar_xso.Info.url.tag
)
self.assertIsInstance(
avatar_xso.Info.url.type_,
xso.String
)
self.assertIs(
avatar_xso.Info.height.default,
None
)
def test_width(self):
self.assertIsInstance(
avatar_xso.Info.width,
xso.Attr
)
self.assertEqual(
(None, "width"),
avatar_xso.Info.width.tag
)
self.assertIsInstance(
avatar_xso.Info.width.type_,
xso.Integer
)
self.assertIs(
avatar_xso.Info.height.default,
None
)
class TestPointer(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(avatar_xso.Pointer, xso.XSO))
def test_tag(self):
self.assertEqual(
(namespaces.xep0084_metadata, "pointer"),
avatar_xso.Pointer.TAG
)
def test_init(self):
payload = unittest.mock.sentinel.payload
pointer = avatar_xso.Pointer(payload,
id_="123",
mime_type="image/png",
nbytes=3)
self.assertEqual(pointer.registered_payload, payload)
self.assertEqual(pointer.id_, "123")
self.assertEqual(pointer.mime_type, "image/png")
self.assertEqual(pointer.nbytes, 3)
self.assertEqual(pointer.width, None)
self.assertEqual(pointer.height, None)
pointer = avatar_xso.Pointer(payload,
id_="123",
mime_type="image/png",
nbytes=3,
width=10)
self.assertEqual(pointer.registered_payload, payload)
self.assertEqual(pointer.id_, "123")
self.assertEqual(pointer.mime_type, "image/png")
self.assertEqual(pointer.nbytes, 3)
self.assertEqual(pointer.width, 10)
self.assertEqual(pointer.height, None)
pointer = avatar_xso.Pointer(payload,
id_="123",
mime_type="image/png",
nbytes=3,
height=10)
self.assertEqual(pointer.registered_payload, payload)
self.assertEqual(pointer.id_, "123")
self.assertEqual(pointer.mime_type, "image/png")
self.assertEqual(pointer.nbytes, 3)
self.assertEqual(pointer.width, None)
self.assertEqual(pointer.height, 10)
def test_registered_payload(self):
self.assertIsInstance(
avatar_xso.Pointer.registered_payload,
xso.Child
)
def test_unregistered_payload(self):
self.assertIsInstance(
avatar_xso.Pointer.unregistered_payload,
xso.Collector
)
def test_nbytes(self):
self.assertIsInstance(
avatar_xso.Pointer.nbytes,
xso.Attr
)
self.assertEqual(
(None, "bytes"),
avatar_xso.Pointer.nbytes.tag
)
self.assertIsInstance(
avatar_xso.Pointer.nbytes.type_,
xso.Integer
)
self.assertIs(
avatar_xso.Pointer.nbytes.default,
None
)
def test_height(self):
self.assertIsInstance(
avatar_xso.Pointer.height,
xso.Attr
)
self.assertEqual(
(None, "height"),
avatar_xso.Pointer.height.tag
)
self.assertIsInstance(
avatar_xso.Pointer.height.type_,
xso.Integer
)
self.assertIs(
avatar_xso.Pointer.height.default,
None
)
def test_id(self):
self.assertIsInstance(
avatar_xso.Pointer.id_,
xso.Attr
)
self.assertEqual(
(None, "id"),
avatar_xso.Pointer.id_.tag
)
self.assertIsInstance(
avatar_xso.Pointer.id_.type_,
xso.String
)
self.assertIs(
avatar_xso.Pointer.id_.default,
None
)
def test_mime_type(self):
self.assertIsInstance(
avatar_xso.Pointer.mime_type,
xso.Attr
)
self.assertEqual(
(None, "type"),
avatar_xso.Pointer.mime_type.tag
)
self.assertIsInstance(
avatar_xso.Pointer.mime_type.type_,
xso.String
)
self.assertIs(
avatar_xso.Pointer.mime_type.default,
None
)
def test_width(self):
self.assertIsInstance(
avatar_xso.Pointer.width,
xso.Attr
)
self.assertEqual(
(None, "width"),
avatar_xso.Pointer.width.tag
)
self.assertIsInstance(
avatar_xso.Pointer.width.type_,
xso.Integer
)
self.assertIs(
avatar_xso.Pointer.height.default,
None
)
def test_as_payload_class(self):
with contextlib.ExitStack() as stack:
at_Pointer = stack.enter_context(
unittest.mock.patch.object(
avatar_xso.Pointer,
"register_child"
)
)
result = avatar_xso.Pointer.as_payload_class(
unittest.mock.sentinel.cls
)
self.assertIs(result, unittest.mock.sentinel.cls)
at_Pointer.assert_called_with(
avatar_xso.Pointer.registered_payload,
unittest.mock.sentinel.cls
)
tests/blocking/ 0000775 0000000 0000000 00000000000 14160146213 0014006 5 ustar 00root root 0000000 0000000 tests/blocking/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0016124 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/blocking/test_e2e.py 0000664 0000000 0000000 00000007632 14160146213 0016102 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_e2e.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
# .
#
########################################################################
import asyncio
import logging
import unittest.mock
import aioxmpp
from aioxmpp.utils import namespaces
from aioxmpp.e2etest import (
blocking,
blocking_timed,
require_feature,
TestCase,
)
TEST_JID1 = aioxmpp.structs.JID.fromstr("bar@bar.example/baz")
TEST_JID2 = aioxmpp.structs.JID.fromstr("baz@bar.example/baz")
TEST_JID3 = aioxmpp.structs.JID.fromstr("quux@bar.example/baz")
class TestBlocking(TestCase):
@require_feature(namespaces.xep0191)
def setUp(self, *features):
pass
async def make_client(self, run_before=None):
return await self.provisioner.get_connected_client(
services=[
aioxmpp.DiscoClient,
aioxmpp.BlockingClient,
],
prepare=run_before,
)
@blocking_timed
async def test_blocklist(self):
initial_future = asyncio.Future()
block_future = asyncio.Future()
unblock_future = asyncio.Future()
unblock_all_future = asyncio.Future()
async def connect_initial_signal(client):
blocking = client.summon(aioxmpp.BlockingClient)
blocking.on_initial_blocklist_received.connect(
initial_future,
blocking.on_initial_blocklist_received.AUTO_FUTURE
)
client = await self.make_client(connect_initial_signal)
blocking = client.summon(aioxmpp.BlockingClient)
logging.info("waiting for initial blocklist")
initial_blocklist = await initial_future
self.assertEqual(initial_blocklist, frozenset())
self.assertEqual(blocking.blocklist, frozenset())
blocking.on_jids_blocked.connect(
block_future,
blocking.on_jids_blocked.AUTO_FUTURE
)
await blocking.block_jids([TEST_JID1, TEST_JID2, TEST_JID3])
logging.info("waiting for update on block")
blocked_jids = await block_future
self.assertEqual(blocked_jids,
frozenset([TEST_JID1, TEST_JID2, TEST_JID3]))
self.assertEqual(blocking.blocklist,
frozenset([TEST_JID1, TEST_JID2, TEST_JID3]))
blocking.on_jids_unblocked.connect(
unblock_future,
blocking.on_jids_unblocked.AUTO_FUTURE
)
await blocking.unblock_jids([TEST_JID1])
logging.info("waiting for update on unblock")
unblocked_jids = await unblock_future
self.assertEqual(unblocked_jids,
frozenset([TEST_JID1]))
self.assertEqual(blocking.blocklist,
frozenset([TEST_JID2, TEST_JID3]))
blocking.on_jids_unblocked.connect(
unblock_all_future,
blocking.on_jids_unblocked.AUTO_FUTURE
)
await blocking.unblock_all()
logging.info("waiting for update on unblock all")
unblocked_all_jids = await unblock_all_future
self.assertEqual(unblocked_all_jids,
frozenset([TEST_JID2, TEST_JID3]))
self.assertEqual(blocking.blocklist,
frozenset())
tests/blocking/test_service.py 0000664 0000000 0000000 00000034140 14160146213 0017061 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import contextlib
import unittest
import unittest.mock
import aioxmpp
import aioxmpp.service as service
import aioxmpp.disco.xso as disco_xso
import aioxmpp.blocking as blocking
import aioxmpp.blocking.xso as blocking_xso
from aioxmpp.utils import namespaces
from aioxmpp.testutils import (
make_connected_client,
CoroutineMock,
run_coroutine,
)
TEST_FROM = aioxmpp.structs.JID.fromstr("foo@bar.example/baz")
TEST_JID1 = aioxmpp.structs.JID.fromstr("bar@bar.example/baz")
TEST_JID2 = aioxmpp.structs.JID.fromstr("baz@bar.example/baz")
TEST_JID3 = aioxmpp.structs.JID.fromstr("quux@bar.example/baz")
class TestBlockingClient(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.cc.local_jid = TEST_FROM
self.disco = aioxmpp.DiscoClient(self.cc)
self.s = blocking.BlockingClient(
self.cc,
dependencies={
aioxmpp.DiscoClient: self.disco,
}
)
def tearDown(self):
del self.cc
del self.disco
del self.s
def test_is_service(self):
self.assertTrue(issubclass(
blocking.BlockingClient,
aioxmpp.service.Service
))
def test_service_order(self):
self.assertIn(
aioxmpp.DiscoClient,
blocking.BlockingClient.ORDER_AFTER,
)
def test_get_initial_blocklist_is_depsignal_handler(self):
self.assertTrue(aioxmpp.service.is_depsignal_handler(
aioxmpp.Client,
"before_stream_established",
self.s._get_initial_blocklist
))
def test_handle_stream_destroyed_is_depsignal_handler(self):
self.assertTrue(aioxmpp.service.is_depsignal_handler(
aioxmpp.stream.StanzaStream,
"on_stream_destroyed",
self.s.handle_stream_destroyed
))
def test_check_for_blocking(self):
disco_info = disco_xso.InfoQuery()
disco_info.features.add(namespaces.xep0191)
with unittest.mock.patch.object(self.disco, "query_info",
new=CoroutineMock()):
self.disco.query_info.return_value = disco_info
run_coroutine(self.s._check_for_blocking())
self.disco.query_info.assert_called_with(
TEST_FROM.replace(localpart=None, resource=None)
)
def test_check_for_blocking_failure(self):
disco_info = disco_xso.InfoQuery()
with unittest.mock.patch.object(self.disco, "query_info",
new=CoroutineMock()):
self.disco.query_info.return_value = disco_info
with self.assertRaises(RuntimeError):
run_coroutine(self.s._check_for_blocking())
self.disco.query_info.assert_called_with(
TEST_FROM.replace(localpart=None, resource=None)
)
def test_get_initial_blocklist(self):
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch.object(
self.s, "_check_for_blocking",
new=CoroutineMock()
)
)
stack.enter_context(
unittest.mock.patch.object(
self.cc, "send",
new=CoroutineMock()
)
)
handle_initial_blocklist_mock = unittest.mock.Mock()
self.s.on_initial_blocklist_received.connect(
handle_initial_blocklist_mock
)
BLOCKLIST = [TEST_JID1, TEST_JID2]
blocklist = blocking_xso.BlockList()
blocklist.items[:] = BLOCKLIST
self.cc.send.return_value = blocklist
result = run_coroutine(self.s._get_initial_blocklist())
self.assertCountEqual(
self.s._blocklist,
BLOCKLIST
)
self.assertEqual(len(self.cc.send.mock_calls), 1)
(_, (arg,), _), = self.cc.send.mock_calls
self.assertIsInstance(arg, aioxmpp.IQ)
self.assertEqual(arg.type_, aioxmpp.IQType.GET)
self.assertIsInstance(arg.payload, blocking_xso.BlockList)
self.assertEqual(
len(arg.payload.items),
0
)
self.assertSequenceEqual(
self.s._check_for_blocking.mock_calls,
[unittest.mock.call()]
)
self.assertSequenceEqual(
handle_initial_blocklist_mock.mock_calls,
[
unittest.mock.call(
frozenset(BLOCKLIST)
)
]
)
self.assertTrue(result) # so that coroutine doesn’t get disconnected
def test_get_initial_blocklist_handles_exception(self):
with contextlib.ExitStack() as stack:
_check_for_blocking = stack.enter_context(
unittest.mock.patch.object(
self.s, "_check_for_blocking",
new=CoroutineMock()
)
)
_check_for_blocking.side_effect = RuntimeError()
stack.enter_context(
unittest.mock.patch.object(
self.cc, "send",
new=CoroutineMock()
)
)
handle_initial_blocklist_mock = unittest.mock.Mock()
self.s.on_initial_blocklist_received.connect(
handle_initial_blocklist_mock
)
BLOCKLIST = [TEST_JID1, TEST_JID2]
blocklist = blocking_xso.BlockList()
blocklist.items[:] = BLOCKLIST
self.cc.send.return_value = blocklist
result = run_coroutine(self.s._get_initial_blocklist())
self.assertIsNone(self.s._blocklist)
self.cc.send.assert_not_called()
self.s._check_for_blocking.assert_called_once_with()
self.assertTrue(result) # so that coroutine doesn’t get disconnected
def test_block_jids(self):
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch.object(
self.s, "_check_for_blocking",
new=CoroutineMock()
)
)
stack.enter_context(
unittest.mock.patch.object(
self.cc, "send",
new=CoroutineMock()
)
)
run_coroutine(self.s.block_jids([TEST_JID1]))
self.assertSequenceEqual(
self.s._check_for_blocking.mock_calls,
[unittest.mock.call()]
)
self.assertEqual(len(self.cc.send.mock_calls), 1)
(_, (arg,), _), = self.cc.send.mock_calls
self.assertIsInstance(arg, aioxmpp.IQ)
self.assertEqual(arg.type_, aioxmpp.IQType.SET)
self.assertIsInstance(arg.payload, blocking_xso.BlockCommand)
self.assertCountEqual(
arg.payload.items,
frozenset([TEST_JID1]),
)
def test_block_jids_ignore_empty(self):
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch.object(
self.s, "_check_for_blocking",
new=CoroutineMock()
)
)
stack.enter_context(
unittest.mock.patch.object(
self.cc, "send",
new=CoroutineMock()
)
)
run_coroutine(self.s.block_jids([]))
self.assertSequenceEqual(
self.s._check_for_blocking.mock_calls,
[unittest.mock.call()]
)
self.assertSequenceEqual(self.cc.send.mock_calls, [])
def test_unblock_jids(self):
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch.object(
self.s, "_check_for_blocking",
new=CoroutineMock()
)
)
stack.enter_context(
unittest.mock.patch.object(
self.cc, "send",
new=CoroutineMock()
)
)
run_coroutine(self.s.unblock_jids([TEST_JID2]))
self.assertSequenceEqual(
self.s._check_for_blocking.mock_calls,
[unittest.mock.call()]
)
self.assertEqual(len(self.cc.send.mock_calls), 1)
(_, (arg,), _), = self.cc.send.mock_calls
self.assertIsInstance(arg, aioxmpp.IQ)
self.assertEqual(arg.type_, aioxmpp.IQType.SET)
self.assertIsInstance(arg.payload, blocking_xso.UnblockCommand)
self.assertCountEqual(
arg.payload.items,
frozenset([TEST_JID2]),
)
def test_unblock_jids_ignore_empty(self):
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch.object(
self.s, "_check_for_blocking",
new=CoroutineMock()
)
)
stack.enter_context(
unittest.mock.patch.object(
self.cc, "send",
new=CoroutineMock()
)
)
run_coroutine(self.s.unblock_jids([]))
self.assertSequenceEqual(
self.s._check_for_blocking.mock_calls,
[unittest.mock.call()]
)
self.assertSequenceEqual(self.cc.send.mock_calls, [])
def test_unblock_all(self):
with contextlib.ExitStack() as stack:
stack.enter_context(
unittest.mock.patch.object(
self.s, "_check_for_blocking",
new=CoroutineMock()
)
)
stack.enter_context(
unittest.mock.patch.object(
self.cc, "send",
new=CoroutineMock()
)
)
run_coroutine(self.s.unblock_all())
self.assertSequenceEqual(
self.s._check_for_blocking.mock_calls,
[unittest.mock.call()]
)
def test_handle_block_push_is_iq_handler(self):
service.is_iq_handler(aioxmpp.IQType.SET,
blocking_xso.BlockCommand,
self.s.handle_block_push)
def test_handle_block_push(self):
handle_block = unittest.mock.Mock()
handle_unblock = unittest.mock.Mock()
self.s.on_jids_blocked.connect(
handle_block
)
self.s.on_jids_unblocked.connect(
handle_unblock
)
self.s._blocklist = frozenset([TEST_JID1])
block = blocking_xso.BlockCommand()
block.items[:] = [TEST_JID2]
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=block,
)
run_coroutine(self.s.handle_block_push(iq))
self.assertEqual(
self.s._blocklist,
frozenset([TEST_JID1, TEST_JID2])
)
self.assertEqual(
handle_block.mock_calls,
[
unittest.mock.call(
frozenset([TEST_JID2])
)
]
)
handle_unblock.assert_not_called()
def test_handle_unblock_push_is_iq_handler(self):
service.is_iq_handler(aioxmpp.IQType.SET,
blocking_xso.UnblockCommand,
self.s.handle_unblock_push)
def test_handle_unblock_push(self):
handle_block = unittest.mock.Mock()
handle_unblock = unittest.mock.Mock()
self.s.on_jids_blocked.connect(
handle_block
)
self.s.on_jids_unblocked.connect(
handle_unblock
)
self.s._blocklist = frozenset([TEST_JID1, TEST_JID2])
block = blocking_xso.UnblockCommand()
block.items[:] = [TEST_JID2]
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=block,
)
run_coroutine(self.s.handle_unblock_push(iq))
self.assertEqual(
self.s._blocklist,
frozenset([TEST_JID1])
)
self.assertEqual(
handle_unblock.mock_calls,
[
unittest.mock.call(
frozenset([TEST_JID2])
)
]
)
handle_block.assert_not_called()
def test_handle_unblock_push_all(self):
handle_block = unittest.mock.Mock()
handle_unblock = unittest.mock.Mock()
self.s.on_jids_blocked.connect(
handle_block
)
self.s.on_jids_unblocked.connect(
handle_unblock
)
self.s._blocklist = frozenset([TEST_JID1, TEST_JID2])
block = blocking_xso.UnblockCommand()
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=block,
)
run_coroutine(self.s.handle_unblock_push(iq))
self.assertEqual(
handle_unblock.mock_calls,
[
unittest.mock.call(
frozenset([TEST_JID1, TEST_JID2])
)
]
)
handle_block.assert_not_called()
tests/blocking/test_xso.py 0000664 0000000 0000000 00000005503 14160146213 0016233 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import unittest
import aioxmpp
import aioxmpp.xso as xso
import aioxmpp.blocking.xso as blocking_xso
from aioxmpp.utils import namespaces
TEST_JID1 = aioxmpp.JID.fromstr("foo@example.com")
TEST_JID2 = aioxmpp.JID.fromstr("spam.example.com")
class TestNamespace(unittest.TestCase):
def test_namespace(self):
self.assertEqual(
namespaces.xep0191,
"urn:xmpp:blocking"
)
class TestBlockList(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(blocking_xso.BlockList, xso.XSO))
def test_tag(self):
self.assertEqual(
blocking_xso.BlockList.TAG,
(namespaces.xep0191, "blocklist"),
)
class TestBlockCommand(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(blocking_xso.BlockCommand, xso.XSO))
def test_tag(self):
self.assertEqual(
blocking_xso.BlockCommand.TAG,
(namespaces.xep0191, "block"),
)
def test_init(self):
block_command = blocking_xso.BlockCommand([TEST_JID1, TEST_JID2])
self.assertCountEqual(
block_command.items,
[TEST_JID1, TEST_JID2]
)
block_command = blocking_xso.BlockCommand()
self.assertCountEqual(
block_command.items,
[]
)
class TestUnblockCommand(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(blocking_xso.UnblockCommand, xso.XSO))
def test_tag(self):
self.assertEqual(
blocking_xso.UnblockCommand.TAG,
(namespaces.xep0191, "unblock"),
)
def test_init(self):
unblock_command = blocking_xso.UnblockCommand([TEST_JID1, TEST_JID2])
self.assertCountEqual(
unblock_command.items,
[TEST_JID1, TEST_JID2]
)
unblock_command = blocking_xso.UnblockCommand()
self.assertCountEqual(
unblock_command.items,
[]
)
tests/bookmarks/ 0000775 0000000 0000000 00000000000 14160146213 0014206 5 ustar 00root root 0000000 0000000 tests/bookmarks/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0016324 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/bookmarks/test_e2e.py 0000664 0000000 0000000 00000011607 14160146213 0016277 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_e2e.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
# .
#
########################################################################
import asyncio
import aioxmpp.bookmarks
from aioxmpp.e2etest import (
blocking_timed,
skip_with_quirk,
Quirk,
TestCase,
)
class TestBookmarks(TestCase):
@skip_with_quirk(Quirk.NO_PRIVATE_XML)
@blocking_timed
async def setUp(self):
self.client = await self.provisioner.get_connected_client(
services=[aioxmpp.BookmarkClient]
)
self.s = self.client.summon(aioxmpp.BookmarkClient)
@blocking_timed
async def test_store_and_retrieve(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
await self.s.add_bookmark(bookmark)
bookmarks = await self.s.get_bookmarks()
self.assertIn(bookmark, bookmarks)
self.assertIsNot(bookmark, bookmarks[0])
@blocking_timed
async def test_add_event(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
added_future = asyncio.Future()
def handler(bookmark):
added_future.set_result(bookmark)
return True # disconnect
self.s.on_bookmark_added.connect(handler)
await self.s.add_bookmark(bookmark)
self.assertTrue(added_future.done())
self.assertEqual(added_future.result(), bookmark)
self.assertIsNot(added_future.result(), bookmark)
@blocking_timed
async def test_store_and_remove(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
await self.s.add_bookmark(bookmark)
await self.s.discard_bookmark(bookmark)
bookmarks = await self.s.get_bookmarks()
self.assertNotIn(bookmark, bookmarks)
@blocking_timed
async def test_remove_event(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
await self.s.add_bookmark(bookmark)
removed_future = asyncio.Future()
def handler(bookmark):
removed_future.set_result(bookmark)
return True # disconnect
self.s.on_bookmark_removed.connect(handler)
await self.s.discard_bookmark(bookmark)
self.assertTrue(removed_future.done())
self.assertEqual(removed_future.result(), bookmark)
self.assertIsNot(removed_future.result(), bookmark)
@blocking_timed
async def test_store_and_update(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
await self.s.add_bookmark(bookmark)
updated_bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit"),
nick="firstwitch",
autojoin=True,
)
await self.s.update_bookmark(bookmark, updated_bookmark)
bookmarks = await self.s.get_bookmarks()
self.assertNotIn(bookmark, bookmarks)
self.assertIn(updated_bookmark, bookmarks)
@blocking_timed
async def test_change_event(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
await self.s.add_bookmark(bookmark)
changed_future = asyncio.Future()
def handler(old, new):
changed_future.set_result((old, new))
return True # disconnect
self.s.on_bookmark_changed.connect(handler)
updated_bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit"),
nick="firstwitch",
autojoin=True,
)
await self.s.update_bookmark(bookmark, updated_bookmark)
self.assertTrue(changed_future.done())
old, new = changed_future.result()
self.assertEqual(old, bookmark)
self.assertEqual(new, updated_bookmark)
tests/bookmarks/test_service.py 0000664 0000000 0000000 00000060045 14160146213 0017264 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import asyncio
import contextlib
import copy
import logging
import random
import unittest
import unittest.mock
import aioxmpp
import aioxmpp.private_xml
import aioxmpp.xso
import aioxmpp.bookmarks
from aioxmpp.testutils import (
make_connected_client,
CoroutineMock,
run_coroutine
)
TEST_JID1 = aioxmpp.JID.fromstr("foo@bar.baz")
TEST_JID2 = aioxmpp.JID.fromstr("bar@bar.baz")
class PrivateXMLSimulator:
def __init__(self, *, delay=0):
self.stored = {}
self.delay = 0
async def get_private_xml(self, xso):
payload = copy.deepcopy(
self.stored.setdefault(xso.TAG[0], copy.deepcopy(xso))
)
return aioxmpp.private_xml.Query(payload)
async def set_private_xml(self, xso):
if self.delay == 0:
self.stored[xso.TAG[0]] = copy.deepcopy(xso)
else:
self.delay -= 1
@aioxmpp.private_xml.Query.as_payload_class
class ExampleXSO(aioxmpp.xso.XSO):
TAG = ("urn:example:foo", "example")
text = aioxmpp.xso.Text()
def __init__(self, text=""):
self.text = text
class TestPrivateXMLSimulator(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.private_xml = PrivateXMLSimulator()
def tearDown(self):
del self.cc
del self.private_xml
def test_retrieve_store_and_retrieve(self):
before = None
after = None
async def test_private_xml():
nonlocal before, after
before = (
await self.private_xml.get_private_xml(ExampleXSO())
).registered_payload
await self.private_xml.set_private_xml(ExampleXSO("foo"))
after = (
await self.private_xml.get_private_xml(ExampleXSO())
).registered_payload
run_coroutine(test_private_xml())
self.assertIsInstance(before, ExampleXSO)
self.assertEqual(before.text, "")
self.assertIsInstance(after, ExampleXSO)
self.assertEqual(after.text, "foo")
def test_store_and_retrieve(self):
async def test_private_xml():
await self.private_xml.set_private_xml(ExampleXSO("foo"))
return (
await self.private_xml.get_private_xml(ExampleXSO())
).registered_payload
res = run_coroutine(test_private_xml())
self.assertIsInstance(res, ExampleXSO)
self.assertEqual(res.text, "foo")
def test_store_delay(self):
results = []
self.private_xml.delay = 3
async def test_private_xml():
for i in range(5):
await self.private_xml.set_private_xml(ExampleXSO("foo"))
results.append((
await self.private_xml.get_private_xml(ExampleXSO())
).registered_payload)
run_coroutine(test_private_xml())
self.assertEqual(results[0].text, "")
self.assertEqual(results[1].text, "")
self.assertEqual(results[2].text, "")
self.assertEqual(results[3].text, "foo")
self.assertEqual(results[4].text, "foo")
class TestBookmarkClient(unittest.TestCase):
def test_is_service(self):
self.assertTrue(issubclass(
aioxmpp.bookmarks.BookmarkClient,
aioxmpp.service.Service
))
def test_after_private_xml(self):
self.assertIn(
aioxmpp.private_xml.PrivateXMLService,
aioxmpp.bookmarks.BookmarkClient.ORDER_AFTER
)
def test__stream_established_is_decorated(self):
self.assertTrue(
aioxmpp.service.is_depsignal_handler(
aioxmpp.Client,
"on_stream_established",
aioxmpp.bookmarks.BookmarkClient._stream_established,
defer=True,
)
)
def test__stream_established(self):
with unittest.mock.patch.object(
self.s,
"sync",
CoroutineMock()) as sync:
run_coroutine(self.s._stream_established())
self.assertEqual(len(sync.mock_calls), 1)
def setUp(self):
self.cc = make_connected_client()
self.private_xml = PrivateXMLSimulator()
self.s = aioxmpp.bookmarks.BookmarkClient(self.cc, dependencies={
aioxmpp.private_xml.PrivateXMLService: self.private_xml
})
def tearDown(self):
del self.cc
del self.private_xml
del self.s
def connect_mocks(self):
self.on_added = unittest.mock.Mock()
self.on_added.return_value = None
self.on_removed = unittest.mock.Mock()
self.on_removed.return_value = None
self.on_changed = unittest.mock.Mock()
self.on_changed.return_value = None
self.s.on_bookmark_added.connect(self.on_added)
self.s.on_bookmark_removed.connect(self.on_removed)
self.s.on_bookmark_changed.connect(self.on_changed)
def test__get_bookmarks(self):
with unittest.mock.patch.object(
self.private_xml,
"get_private_xml",
new=CoroutineMock()) as get_private_xml_mock:
get_private_xml_mock.return_value.registered_payload.bookmarks = \
unittest.mock.sentinel.result
res = run_coroutine(self.s._get_bookmarks())
self.assertIs(res, unittest.mock.sentinel.result)
self.assertEqual(
len(get_private_xml_mock.mock_calls),
1
)
(_, (arg,), kwargs), = get_private_xml_mock.mock_calls
self.assertEqual(len(kwargs), 0)
self.assertIsInstance(arg, aioxmpp.bookmarks.Storage)
self.assertEqual(len(arg.bookmarks), 0)
def test__set_bookmarks(self):
bookmarks = []
bookmarks.append(
aioxmpp.bookmarks.Conference(
"Coven", aioxmpp.JID.fromstr("coven@example.com")),
)
bookmarks.append(
aioxmpp.bookmarks.URL(
"Interesting",
"http://example.com/"),
)
with unittest.mock.patch.object(
self.private_xml,
"set_private_xml",
new=CoroutineMock()) as set_private_xml_mock:
run_coroutine(self.s._set_bookmarks(bookmarks))
self.assertEqual(
len(set_private_xml_mock.mock_calls),
1
)
(_, (arg,), kwargs), = set_private_xml_mock.mock_calls
self.assertEqual(len(kwargs), 0)
self.assertEqual(arg.bookmarks, bookmarks)
def test__set_bookmarks_failure(self):
bookmarks = unittest.mock.sentinel.something_else
with unittest.mock.patch.object(
self.private_xml,
"set_private_xml",
new=CoroutineMock()) as set_private_xml_mock:
with self.assertRaisesRegex(
TypeError,
"can only assign an iterable$"):
run_coroutine(self.s._set_bookmarks(bookmarks))
self.assertEqual(
len(set_private_xml_mock.mock_calls),
0
)
def test_sync(self):
on_added = unittest.mock.Mock()
on_added.return_value = None
on_removed = unittest.mock.Mock()
on_removed.return_value = None
on_changed = unittest.mock.Mock()
on_changed.return_value = None
self.s.on_bookmark_added.connect(on_added)
self.s.on_bookmark_removed.connect(on_removed)
self.s.on_bookmark_changed.connect(on_changed)
with unittest.mock.patch.object(
self.private_xml,
"get_private_xml",
new=CoroutineMock()) as get_private_xml_mock:
result = aioxmpp.private_xml.Query(
aioxmpp.bookmarks.Storage()
)
result.registered_payload.bookmarks.append(
aioxmpp.bookmarks.Conference(
jid=aioxmpp.JID.fromstr("foo@bar.baz"),
name="foo",
nick="quux"
)
)
get_private_xml_mock.return_value = result
run_coroutine(self.s.sync())
run_coroutine(self.s.sync())
result = aioxmpp.private_xml.Query(
aioxmpp.bookmarks.Storage()
)
result.registered_payload.bookmarks.append(
aioxmpp.bookmarks.Conference(
jid=TEST_JID1,
name="foo",
nick="quux"
)
)
result.registered_payload.bookmarks.append(
aioxmpp.bookmarks.Conference(
jid=TEST_JID1,
name="foo",
nick="quuux"
)
)
get_private_xml_mock.return_value = result
run_coroutine(self.s.sync())
run_coroutine(self.s.sync())
result = aioxmpp.private_xml.Query(
aioxmpp.bookmarks.Storage()
)
result.registered_payload.bookmarks.append(
aioxmpp.bookmarks.Conference(
jid=TEST_JID1,
name="foo",
nick="quux"
)
)
get_private_xml_mock.return_value = result
run_coroutine(self.s.sync())
run_coroutine(self.s.sync())
result = aioxmpp.private_xml.Query(
aioxmpp.bookmarks.Storage()
)
result.registered_payload.bookmarks.append(
aioxmpp.bookmarks.Conference(
jid=aioxmpp.JID.fromstr("foo@bar.baz"),
name="foo",
nick="quuux"
)
)
get_private_xml_mock.return_value = result
run_coroutine(self.s.sync())
run_coroutine(self.s.sync())
self.assertEqual(
len(on_added.mock_calls), 2
)
self.assertEqual(
len(on_removed.mock_calls), 1
)
self.assertEqual(
len(on_changed.mock_calls), 1
)
def test_set_bookmarks(self):
bookmarks = [
aioxmpp.bookmarks.URL("An URL", "http://foo.bar/"),
aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@conference.shakespeare.lit"),
nick="Wayward Sister"
)
]
self.connect_mocks()
run_coroutine(self.s.set_bookmarks(bookmarks))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 2)
def test_add_bookmark(self):
self.connect_mocks()
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.on_added.assert_called_once_with(bookmark)
def test_add_bookmark_delay(self):
self.private_xml.delay = 3
self.connect_mocks()
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.on_added.assert_called_once_with(bookmark)
def test_add_bookmark_delay_raises(self):
self.private_xml.delay = 4
with self.assertRaises(RuntimeError):
with unittest.mock.patch.object(self.s, "_diff_emit_update") as f:
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
# check that _diff_emit_update is called
self.assertEqual(len(f.mock_calls), 1)
def test_add_bookmark_set_raises(self):
class TokenException(Exception):
pass
def set_bookmarks(*args, **kwargs):
raise TokenException
with contextlib.ExitStack() as e:
e.enter_context(self.assertRaises(TokenException))
diff_emit_update = e.enter_context(
unittest.mock.patch.object(self.s, "_diff_emit_update",)
)
e.enter_context(
unittest.mock.patch.object(self.s, "_set_bookmarks",
set_bookmarks)
)
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
# check that _diff_emit_update is called
self.assertEqual(len(diff_emit_update.mock_calls), 1)
def test_add_bookmark_already_present(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
stored = run_coroutine(
self.private_xml.get_private_xml(aioxmpp.bookmarks.Storage())
)
self.assertCountEqual(self.s._bookmark_cache,
[bookmark])
self.assertCountEqual(stored.registered_payload.bookmarks,
[bookmark])
self.connect_mocks()
run_coroutine(self.s.add_bookmark(bookmark))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
def test_discard_bookmark(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.connect_mocks()
run_coroutine(self.s.discard_bookmark(bookmark))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
self.on_removed.assert_called_once_with(bookmark)
def test_discard_bookmark_delay(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.private_xml.delay = 3
self.connect_mocks()
run_coroutine(self.s.discard_bookmark(bookmark))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
self.on_removed.assert_called_once_with(bookmark)
def test_discard_bookmark_delay_raises(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.private_xml.delay = 4
with contextlib.ExitStack() as e:
e.enter_context(self.assertRaises(RuntimeError))
f = e.enter_context(
unittest.mock.patch.object(self.s, "_diff_emit_update")
)
run_coroutine(self.s.discard_bookmark(bookmark))
# check that _diff_emit_update is called
self.assertEqual(len(f.mock_calls), 1)
def test_discard_bookmark_set_raises(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
class TokenException(Exception):
pass
def set_bookmarks(*args, **kwargs):
raise TokenException
with contextlib.ExitStack() as e:
e.enter_context(self.assertRaises(TokenException))
diff_emit_update = e.enter_context(
unittest.mock.patch.object(self.s, "_diff_emit_update",)
)
e.enter_context(
unittest.mock.patch.object(self.s, "_set_bookmarks",
set_bookmarks)
)
run_coroutine(self.s.discard_bookmark(bookmark))
# check that _diff_emit_update is called
self.assertEqual(len(diff_emit_update.mock_calls), 1)
def test_discard_bookmark_removes_one(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.set_bookmarks([bookmark, bookmark]))
self.connect_mocks()
run_coroutine(self.s.discard_bookmark(bookmark))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
self.on_removed.assert_called_once_with(bookmark)
self.assertCountEqual(self.s._bookmark_cache, [bookmark])
def test_discard_bookmark_already_gone(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
self.connect_mocks()
run_coroutine(self.s.discard_bookmark(bookmark))
self.assertEqual(len(self.on_changed.mock_calls), 0)
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
def test_update_bookmark(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.connect_mocks()
new_bookmark = copy.copy(bookmark)
new_bookmark.name = "THE URL"
run_coroutine(self.s.update_bookmark(bookmark, new_bookmark))
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
self.on_changed.assert_called_once_with(bookmark, new_bookmark)
def test_update_bookmark_delay(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.private_xml.delay = 3
self.connect_mocks()
new_bookmark = copy.copy(bookmark)
new_bookmark.name = "THE URL"
run_coroutine(self.s.update_bookmark(bookmark, new_bookmark))
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
self.on_changed.assert_called_once_with(bookmark, new_bookmark)
def test_update_bookmark_delay_raises(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
new_bookmark = copy.copy(bookmark)
new_bookmark.name = "THE URL"
run_coroutine(self.s.add_bookmark(bookmark))
self.private_xml.delay = 4
with contextlib.ExitStack() as e:
e.enter_context(self.assertRaises(RuntimeError))
f = e.enter_context(
unittest.mock.patch.object(self.s, "_diff_emit_update")
)
run_coroutine(self.s.update_bookmark(bookmark, new_bookmark))
# check that _diff_emit_update is called
self.assertEqual(len(f.mock_calls), 1)
def test_update_bookmark_set_raises(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
new_bookmark = copy.copy(bookmark)
new_bookmark.name = "THE URL"
run_coroutine(self.s.add_bookmark(bookmark))
class TokenException(Exception):
pass
def set_bookmarks(*args, **kwargs):
raise TokenException
with contextlib.ExitStack() as e:
e.enter_context(self.assertRaises(TokenException))
diff_emit_update = e.enter_context(
unittest.mock.patch.object(self.s, "_diff_emit_update",)
)
e.enter_context(
unittest.mock.patch.object(self.s, "_set_bookmarks",
set_bookmarks)
)
run_coroutine(self.s.update_bookmark(bookmark, new_bookmark))
# check that _diff_emit_update is called
self.assertEqual(len(diff_emit_update.mock_calls), 1)
def test_concurrent_update_bookmark(self):
bookmark = aioxmpp.bookmarks.URL("An URL", "http://foo.bar/")
run_coroutine(self.s.add_bookmark(bookmark))
self.private_xml.stored["storage:bookmarks"].bookmarks.clear()
self.connect_mocks()
new_bookmark = copy.copy(bookmark)
new_bookmark.name = "THE URL"
run_coroutine(self.s.update_bookmark(bookmark, new_bookmark))
self.assertEqual(len(self.on_removed.mock_calls), 0)
self.assertEqual(len(self.on_added.mock_calls), 0)
self.on_changed.assert_called_once_with(bookmark, new_bookmark)
def test_on_change_from_two_branches(self):
pass
def test_fuzz_bookmark_changes(self):
bookmark_list = []
logging.info(
"This is a fuzzing test it may fail or not fail randomly"
" depending on the chosen seed."
"If it fails, please report a bug which includes "
"the random generator state given in the next log message"
)
logging.info("The random seed is %s", random.getstate())
def on_added(added):
bookmark_list.append(added)
def on_removed(removed):
bookmark_list.remove(removed)
def on_changed(old, new):
bookmark_list.remove(old)
bookmark_list.append(new)
self.s.on_bookmark_added.connect(on_added)
self.s.on_bookmark_removed.connect(on_removed)
self.s.on_bookmark_changed.connect(on_changed)
def random_nick():
return "foo{}".format(random.randint(0, 5))
def random_name():
return "name{}".format(random.randint(0, 5))
def random_pw():
return "name{}".format(random.randint(0, 5))
jids = [aioxmpp.JID.fromstr("foo{}@bar.baz".format(i))
for i in range(5)]
def random_jid():
return random.choice(jids)
def random_url():
return "http://foo{}.bar/".format(random.randint(0, 5))
for i in range(100):
operation = random.randint(0, 100)
if operation < 20:
if random.randint(0, 1):
bookmark = aioxmpp.bookmarks.Conference(
random_name(),
random_jid(),
nick=random_nick(),
password=random_pw(),
autojoin=bool(random.randint(0, 1)),
)
else:
bookmark = aioxmpp.bookmarks.URL(
random_name(),
random_url(),
)
run_coroutine(self.s.add_bookmark(bookmark))
elif operation < 30:
if not bookmark_list:
continue
run_coroutine(self.s.discard_bookmark(
bookmark_list[random.randrange(len(bookmark_list))]
))
else:
if not bookmark_list:
continue
to_change = bookmark_list[random.randrange(len(bookmark_list))]
changed = copy.copy(to_change)
if type(to_change) is aioxmpp.bookmarks.Conference:
if random.randint(0, 4) == 0:
changed.name = random_name()
if random.randint(0, 4) == 0:
changed.jid = random_jid()
if random.randint(0, 4) == 0:
changed.nick = random_nick()
if random.randint(0, 4) == 0:
changed.password = random_pw()
if random.randint(0, 4) == 0:
changed.autojoin = bool(random.randint(0, 1))
else:
if random.randint(0, 2) == 0:
changed.name = random_name()
if random.randint(0, 2) == 0:
changed.url = random_url()
run_coroutine(self.s.update_bookmark(to_change, changed))
self.assertCountEqual(bookmark_list, self.s._bookmark_cache)
tests/bookmarks/test_xso.py 0000664 0000000 0000000 00000014201 14160146213 0016426 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import unittest
import aioxmpp.structs
import aioxmpp.bookmarks.xso as bookmark_xso
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
EXAMPLE_JID1 = aioxmpp.JID.fromstr("coven@conference.shakespeare.lit")
EXAMPLE_JID2 = aioxmpp.JID.fromstr("open_field@conference.shakespeare.lit")
class TestNamespace(unittest.TestCase):
def test_namespace(self):
self.assertEqual(namespaces.xep0048, "storage:bookmarks")
class TestStorage(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(bookmark_xso.Storage, xso.XSO))
def test_tag(self):
self.assertEqual(
bookmark_xso.Storage.TAG,
(namespaces.xep0048, "storage")
)
class TestConference(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(bookmark_xso.Conference, xso.XSO))
def test_tag(self):
self.assertEqual(
bookmark_xso.Conference.TAG,
(namespaces.xep0048, "conference")
)
def test_init(self):
conference = bookmark_xso.Conference("Coven", EXAMPLE_JID1)
self.assertEqual(conference.name, "Coven")
self.assertEqual(conference.jid, EXAMPLE_JID1)
self.assertEqual(conference.autojoin, False)
self.assertEqual(conference.nick, None)
self.assertEqual(conference.password, None)
conference = bookmark_xso.Conference(
"Coven", EXAMPLE_JID1,
autojoin=True, nick="First Witch",
password="h3c473"
)
self.assertEqual(conference.name, "Coven")
self.assertEqual(conference.jid, EXAMPLE_JID1)
self.assertEqual(conference.autojoin, True)
self.assertEqual(conference.nick, "First Witch")
self.assertEqual(conference.password, "h3c473")
def test_eq(self):
conf = bookmark_xso.Conference("Coven", EXAMPLE_JID1)
conf1 = bookmark_xso.Conference("Coven", EXAMPLE_JID1)
conf2 = bookmark_xso.Conference(
"Coven1", EXAMPLE_JID1,
autojoin=True, nick="First Witch",
password="h3c473"
)
conf3 = bookmark_xso.Conference(
"Coven", EXAMPLE_JID2,
autojoin=True, nick="First Witch",
password="h3c473"
)
conf4 = bookmark_xso.Conference(
"Coven", EXAMPLE_JID1,
nick="First Witch",
password="h3c473"
)
conf5 = bookmark_xso.Conference(
"Coven", EXAMPLE_JID1,
autojoin=True,
password="h3c473"
)
conf6 = bookmark_xso.Conference(
"Coven", EXAMPLE_JID1,
autojoin=True, nick="First Witch"
)
url = bookmark_xso.URL(
"Coven", "xmpp://coven@conference.shakespeare.lit"
)
self.assertEqual(conf, conf)
self.assertEqual(conf, conf1)
self.assertNotEqual(conf, conf2)
self.assertNotEqual(conf, conf3)
self.assertNotEqual(conf, conf4)
self.assertNotEqual(conf, conf5)
self.assertNotEqual(conf, conf6)
self.assertNotEqual(conf, url)
class TestURL(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(bookmark_xso.URL, xso.XSO))
def test_tag(self):
self.assertEqual(
bookmark_xso.URL.TAG,
(namespaces.xep0048, "url")
)
def test_init(self):
url = bookmark_xso.URL("Url1", "http://example.com")
self.assertEqual(url.name, "Url1")
self.assertEqual(url.url, "http://example.com")
def test_eq(self):
url = bookmark_xso.URL("Url", "http://example.com")
url1 = bookmark_xso.URL("Url", "http://example.com")
url2 = bookmark_xso.URL("Url1", "http://example.com")
url3 = bookmark_xso.URL("Url", "http://example.com/foo")
conf = bookmark_xso.Conference("Coven", EXAMPLE_JID1)
self.assertEqual(url, url)
self.assertEqual(url, url1)
self.assertNotEqual(url, url2)
self.assertNotEqual(url, url3)
self.assertNotEqual(url, conf)
@bookmark_xso.as_bookmark_class
class CustomBookmark(bookmark_xso.Bookmark):
TAG = ("urn:example:bookmark", "bookmark")
def __init__(self, name, contents):
self.name = name
self.contents = contents
name = aioxmpp.xso.Attr("name")
contents = aioxmpp.xso.Attr("contents")
@property
def primary(self):
return self.contents
@property
def secondary(self):
return (self.name,)
class TestCustomBookmark(unittest.TestCase):
def test_registered(self):
self.assertIn(
CustomBookmark, bookmark_xso.Storage.bookmarks._classes
)
def test_non_Bookmarks_fail(self):
with self.assertRaises(TypeError):
@bookmark_xso.as_bookmark_class
class CustomBookmark(aioxmpp.xso.XSO):
TAG = ("urn:example:bookmark", "bookmark")
def __init__(self, name, contents):
self.name = name
self.contents = contents
name = aioxmpp.xso.Attr("name")
contents = aioxmpp.xso.Attr("contents")
@property
def primary(self):
return self.contents
@property
def secondary(self):
return (self.name,)
tests/carbons/ 0000775 0000000 0000000 00000000000 14160146213 0013645 5 ustar 00root root 0000000 0000000 tests/carbons/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0015763 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/carbons/test_service.py 0000664 0000000 0000000 00000014105 14160146213 0016717 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import contextlib
import unittest
import aioxmpp
import aioxmpp.carbons.service as carbons_service
import aioxmpp.carbons.xso as carbons_xso
import aioxmpp.service
from aioxmpp.utils import namespaces
from aioxmpp.testutils import (
make_connected_client,
CoroutineMock,
run_coroutine,
)
TEST_JID = aioxmpp.JID.fromstr("romeo@montague.lit/foo")
class TestCarbonsClient(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.cc.local_jid = TEST_JID
self.disco_client = aioxmpp.DiscoClient(self.cc)
self.s = carbons_service.CarbonsClient(
self.cc,
dependencies={
aioxmpp.DiscoClient: self.disco_client,
}
)
def test_is_service(self):
self.assertTrue(issubclass(
carbons_service.CarbonsClient,
aioxmpp.service.Service,
))
def test_requires_DiscoClient(self):
self.assertIn(
aioxmpp.DiscoClient,
carbons_service.CarbonsClient.ORDER_AFTER,
)
def test__check_for_feature_uses_disco(self):
info = unittest.mock.Mock()
info.features = {namespaces.xep0280_carbons_2}
with contextlib.ExitStack() as stack:
query_info = stack.enter_context(unittest.mock.patch.object(
self.disco_client,
"query_info",
new=CoroutineMock(),
))
query_info.return_value = info
run_coroutine(
self.s._check_for_feature()
)
query_info.assert_called_once_with(
self.cc.local_jid.replace(
localpart=None,
resource=None
)
)
def test__check_for_feature_raises_if_feature_not_present(self):
info = unittest.mock.Mock()
info.features = set()
with contextlib.ExitStack() as stack:
query_info = stack.enter_context(unittest.mock.patch.object(
self.disco_client,
"query_info",
new=CoroutineMock(),
))
query_info.return_value = info
with self.assertRaisesRegex(
RuntimeError,
r"Message Carbons \({}\) are not supported by "
"the server".format(
namespaces.xep0280_carbons_2
)):
run_coroutine(
self.s._check_for_feature()
)
query_info.assert_called_once_with(
self.cc.local_jid.replace(
localpart=None,
resource=None
)
)
def test_enable_checks_for_feature_and_sends_iq(self):
with contextlib.ExitStack() as stack:
check_for_feature = stack.enter_context(unittest.mock.patch.object(
self.s,
"_check_for_feature",
new=CoroutineMock()
))
run_coroutine(self.s.enable())
check_for_feature.assert_called_once_with()
self.cc.send.assert_called_once_with(
unittest.mock.ANY,
)
_, (iq,), _ = self.cc.send.mock_calls[0]
self.assertIsInstance(iq, aioxmpp.IQ)
self.assertEqual(iq.type_, aioxmpp.IQType.SET)
self.assertIsInstance(iq.payload, carbons_xso.Enable)
def test_enable_does_not_send_if_feature_not_available(self):
with contextlib.ExitStack() as stack:
check_for_feature = stack.enter_context(unittest.mock.patch.object(
self.s,
"_check_for_feature",
new=CoroutineMock()
))
check_for_feature.side_effect = RuntimeError()
with self.assertRaises(RuntimeError):
run_coroutine(self.s.enable())
check_for_feature.assert_called_once_with()
self.cc.send.assert_not_called()
def test_disable_checks_for_feature_and_sends_iq(self):
with contextlib.ExitStack() as stack:
check_for_feature = stack.enter_context(unittest.mock.patch.object(
self.s,
"_check_for_feature",
new=CoroutineMock()
))
run_coroutine(self.s.disable())
check_for_feature.assert_called_once_with()
self.cc.send.assert_called_once_with(
unittest.mock.ANY,
)
_, (iq,), _ = self.cc.send.mock_calls[0]
self.assertIsInstance(iq, aioxmpp.IQ)
self.assertEqual(iq.type_, aioxmpp.IQType.SET)
self.assertIsInstance(iq.payload, carbons_xso.Disable)
def test_disable_does_not_send_if_feature_not_available(self):
with contextlib.ExitStack() as stack:
check_for_feature = stack.enter_context(unittest.mock.patch.object(
self.s,
"_check_for_feature",
new=CoroutineMock()
))
check_for_feature.side_effect = RuntimeError()
with self.assertRaises(RuntimeError):
run_coroutine(self.s.disable())
check_for_feature.assert_called_once_with()
self.cc.send.assert_not_called()
tests/carbons/test_xso.py 0000664 0000000 0000000 00000011777 14160146213 0016104 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import unittest
import aioxmpp
import aioxmpp.carbons.xso as carbons_xso
import aioxmpp.misc as misc_xso
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
class TestNamespaces(unittest.TestCase):
def test_namespace(self):
self.assertEqual(
namespaces.xep0280_carbons_2,
"urn:xmpp:carbons:2",
)
class TestEnable(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(
issubclass(carbons_xso.Enable, xso.XSO)
)
def test_tag(self):
self.assertEqual(
carbons_xso.Enable.TAG,
(namespaces.xep0280_carbons_2, "enable"),
)
def test_is_iq_payload(self):
self.assertIn(
carbons_xso.Enable,
aioxmpp.IQ.payload._classes,
)
class TestDisable(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(
issubclass(carbons_xso.Disable, xso.XSO)
)
def test_tag(self):
self.assertEqual(
carbons_xso.Disable.TAG,
(namespaces.xep0280_carbons_2, "disable"),
)
def test_is_iq_payload(self):
self.assertIn(
carbons_xso.Disable,
aioxmpp.IQ.payload._classes,
)
class Test_CarbonsWrapper(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(
issubclass(carbons_xso._CarbonsWrapper, xso.XSO)
)
def test_has_no_tag(self):
self.assertFalse(hasattr(carbons_xso._CarbonsWrapper, "TAG"))
def test_forwarded(self):
self.assertIsInstance(
carbons_xso._CarbonsWrapper.forwarded,
xso.Child
)
def test_stanza_returns_None_if_forwarded_is_None(self):
s = carbons_xso._CarbonsWrapper()
self.assertIsNone(s.forwarded)
self.assertIsNone(s.stanza)
def test_stanza_returns_stanza_from_forwarded(self):
s = carbons_xso._CarbonsWrapper()
s.forwarded = misc_xso.Forwarded()
s.forwarded.stanza = unittest.mock.sentinel.foo
self.assertEqual(
s.stanza,
s.forwarded.stanza,
)
def test_setting_stanza_creates_forwarded(self):
s = carbons_xso._CarbonsWrapper()
self.assertIsNone(s.forwarded)
s.stanza = unittest.mock.sentinel.foo
self.assertIsInstance(
s.forwarded,
misc_xso.Forwarded,
)
self.assertEqual(
s.forwarded.stanza,
unittest.mock.sentinel.foo,
)
def test_setting_stanza_reuses_forwarded(self):
forwarded = misc_xso.Forwarded()
s = carbons_xso._CarbonsWrapper()
s.forwarded = forwarded
s.stanza = unittest.mock.sentinel.foo
self.assertIs(
s.forwarded,
forwarded,
)
self.assertEqual(
s.forwarded.stanza,
unittest.mock.sentinel.foo,
)
class TestSent(unittest.TestCase):
def test_is_carbons_wrapper(self):
self.assertTrue(
issubclass(carbons_xso.Sent, carbons_xso._CarbonsWrapper)
)
def test_tag(self):
self.assertEqual(
carbons_xso.Sent.TAG,
(namespaces.xep0280_carbons_2, "sent"),
)
class TestReceived(unittest.TestCase):
def test_is_carbons_wrapper(self):
self.assertTrue(
issubclass(carbons_xso.Received, carbons_xso._CarbonsWrapper)
)
def test_tag(self):
self.assertEqual(
carbons_xso.Received.TAG,
(namespaces.xep0280_carbons_2, "received"),
)
class TestMessage(unittest.TestCase):
def test_xep0280_sent(self):
self.assertIsInstance(
aioxmpp.Message.xep0280_sent,
xso.Child,
)
self.assertSetEqual(
aioxmpp.Message.xep0280_sent._classes,
{
carbons_xso.Sent,
}
)
def test_xep0280_received(self):
self.assertIsInstance(
aioxmpp.Message.xep0280_received,
xso.Child,
)
self.assertSetEqual(
aioxmpp.Message.xep0280_received._classes,
{
carbons_xso.Received,
}
)
tests/chatstates/ 0000775 0000000 0000000 00000000000 14160146213 0014361 5 ustar 00root root 0000000 0000000 tests/chatstates/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0016477 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/chatstates/test_utils.py 0000664 0000000 0000000 00000006027 14160146213 0017137 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import unittest
import unittest.mock
from aioxmpp.chatstates import (ChatState, ChatStateManager, # NOQA
DoNotEmit, AlwaysEmit, DiscoverSupport)
class TestStrategies(unittest.TestCase):
def test_DoNotEmit(self):
strategy = DoNotEmit()
self.assertFalse(strategy.sending)
strategy.reset()
self.assertFalse(strategy.sending)
strategy.no_reply()
self.assertFalse(strategy.sending)
def test_DiscoverSupport(self):
strategy = DiscoverSupport()
self.assertTrue(strategy.sending)
strategy.no_reply()
self.assertFalse(strategy.sending)
strategy.reset()
self.assertTrue(strategy.sending)
def test_AlwaysEmit(self):
strategy = AlwaysEmit()
self.assertTrue(strategy.sending)
strategy.reset()
self.assertTrue(strategy.sending)
strategy.no_reply()
self.assertTrue(strategy.sending)
class TestChatStateManager(unittest.TestCase):
def test_no_reply(self):
manager = ChatStateManager(unittest.mock.Mock())
manager.no_reply()
manager._strategy.no_reply.assert_called_once_with()
def test_reset(self):
manager = ChatStateManager(unittest.mock.Mock())
manager.reset()
manager._strategy.reset.assert_called_once_with()
def test_handle(self):
manager = ChatStateManager(unittest.mock.sentinel)
self.assertIs(manager.handle(ChatState.ACTIVE), False)
self.assertIs(manager.handle(ChatState.INACTIVE),
unittest.mock.sentinel.sending)
self.assertIs(manager.handle(ChatState.INACTIVE), False)
self.assertIs(manager.handle(ChatState.ACTIVE),
unittest.mock.sentinel.sending)
self.assertIs(manager.handle(ChatState.ACTIVE, message=True),
unittest.mock.sentinel.sending)
def test_handle_message_other_than_active_raises(self):
manager = ChatStateManager(unittest.mock.sentinel)
for state in ChatState:
if state != ChatState.ACTIVE:
with self.assertRaises(ValueError):
manager.handle(state, message=True)
tests/chatstates/test_xso.py 0000664 0000000 0000000 00000004060 14160146213 0016603 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import enum
import unittest
import aioxmpp.xso as xso
from aioxmpp.chatstates import ChatState
from aioxmpp.stanza import Message
from aioxmpp.utils import namespaces
class TestNamespace(unittest.TestCase):
def test_namespace(self):
self.assertEqual(namespaces.xep0085,
"http://jabber.org/protocol/chatstates")
class TestChatState(unittest.TestCase):
def test_is_enum(self):
self.assertTrue(issubclass(ChatState, enum.Enum))
def test_values(self):
self.assertEqual(
ChatState.ACTIVE.value, (namespaces.xep0085, "active"),
)
self.assertEqual(
ChatState.COMPOSING.value, (namespaces.xep0085, "composing"),
)
self.assertEqual(
ChatState.PAUSED.value, (namespaces.xep0085, "paused"),
)
self.assertEqual(
ChatState.INACTIVE.value, (namespaces.xep0085, "inactive"),
)
self.assertEqual(
ChatState.GONE.value, (namespaces.xep0085, "gone"),
)
class TestMessage(unittest.TestCase):
def test_xep0085_chatstate(self):
self.assertIsInstance(
Message.xep0085_chatstate,
xso.ChildTag
)
tests/disco/ 0000775 0000000 0000000 00000000000 14160146213 0013317 5 ustar 00root root 0000000 0000000 tests/disco/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0015435 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/disco/test___init__.py 0000664 0000000 0000000 00000003167 14160146213 0016476 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test___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
# .
#
########################################################################
import unittest
import aioxmpp
import aioxmpp.disco as disco
import aioxmpp.disco.service as disco_service
import aioxmpp.disco.xso as disco_xso
class TestExports(unittest.TestCase):
def test_DiscoClient(self):
self.assertIs(disco.DiscoClient, disco_service.DiscoClient)
self.assertIs(aioxmpp.DiscoClient, disco_service.DiscoClient)
def test_DiscoServer(self):
self.assertIs(disco.DiscoServer, disco_service.DiscoServer)
self.assertIs(aioxmpp.DiscoServer, disco_service.DiscoServer)
def test_xso(self):
self.assertIs(disco.xso, disco_xso)
def test_Node(self):
self.assertIs(disco.Node, disco_service.Node)
def test_StaticNode(self):
self.assertIs(disco.StaticNode, disco_service.StaticNode)
tests/disco/test_service.py 0000664 0000000 0000000 00000176500 14160146213 0016401 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import asyncio
import contextlib
import unittest
import sys
import aioxmpp.service as service
import aioxmpp.disco.service as disco_service
import aioxmpp.disco.xso as disco_xso
import aioxmpp.stanza as stanza
import aioxmpp.structs as structs
import aioxmpp.errors as errors
from aioxmpp.utils import namespaces
from aioxmpp.testutils import (
make_connected_client,
run_coroutine,
CoroutineMock,
)
TEST_JID = structs.JID.fromstr("foo@bar.example")
class TestNode(unittest.TestCase):
def test_init(self):
n = disco_service.Node()
self.assertSequenceEqual(
[],
list(n.iter_identities(unittest.mock.sentinel.stanza))
)
self.assertSetEqual(
{namespaces.xep0030_info},
set(n.iter_features(unittest.mock.sentinel.stanza))
)
self.assertSequenceEqual(
[],
list(n.iter_items(unittest.mock.sentinel.stanza))
)
def test_register_feature_adds_the_feature(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_feature("uri:foo")
self.assertSetEqual(
{
"uri:foo",
namespaces.xep0030_info
},
set(n.iter_features(unittest.mock.sentinel.stanza))
)
cb.assert_called_with()
def test_iter_features_works_without_argument(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_feature("uri:foo")
self.assertSetEqual(
{
"uri:foo",
namespaces.xep0030_info
},
set(n.iter_features())
)
cb.assert_called_with()
def test_register_feature_prohibits_duplicate_registration(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_feature("uri:bar")
cb.mock_calls.clear()
with self.assertRaisesRegex(ValueError,
"feature already claimed"):
n.register_feature("uri:bar")
self.assertSetEqual(
{
"uri:bar",
namespaces.xep0030_info
},
set(n.iter_features(unittest.mock.sentinel.stanza))
)
self.assertFalse(cb.mock_calls)
def test_register_feature_prohibits_registration_of_xep0030_features(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
with self.assertRaisesRegex(ValueError,
"feature already claimed"):
n.register_feature(namespaces.xep0030_info)
self.assertFalse(cb.mock_calls)
def test_unregister_feature_removes_the_feature(self):
n = disco_service.Node()
n.register_feature("uri:foo")
n.register_feature("uri:bar")
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
self.assertSetEqual(
{
"uri:foo",
"uri:bar",
namespaces.xep0030_info
},
set(n.iter_features(unittest.mock.sentinel.stanza))
)
n.unregister_feature("uri:foo")
cb.assert_called_with()
cb.mock_calls.clear()
self.assertSetEqual(
{
"uri:bar",
namespaces.xep0030_info
},
set(n.iter_features(unittest.mock.sentinel.stanza))
)
n.unregister_feature("uri:bar")
self.assertSetEqual(
{
namespaces.xep0030_info
},
set(n.iter_features(unittest.mock.sentinel.stanza))
)
cb.assert_called_with()
cb.mock_calls.clear()
def test_unregister_feature_prohibts_removal_of_nonexistant_feature(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
with self.assertRaises(KeyError):
n.unregister_feature("uri:foo")
self.assertFalse(cb.mock_calls)
def test_unregister_feature_prohibts_removal_of_xep0030_features(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
with self.assertRaises(KeyError):
n.unregister_feature(namespaces.xep0030_info)
self.assertSetEqual(
{
namespaces.xep0030_info
},
set(n.iter_features(unittest.mock.sentinel.stanza))
)
self.assertFalse(cb.mock_calls)
def test_register_identity_defines_identity(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_identity(
"client", "pc"
)
self.assertSetEqual(
{
("client", "pc", None, None),
},
set(n.iter_identities(unittest.mock.sentinel.stanza))
)
cb.assert_called_with()
def test_iter_identities_works_without_stanza(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_identity(
"client", "pc"
)
self.assertSetEqual(
{
("client", "pc", None, None),
},
set(n.iter_identities())
)
cb.assert_called_with()
def test_register_identity_prohibits_duplicate_registration(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_identity(
"client", "pc"
)
cb.assert_called_with()
cb.mock_calls.clear()
with self.assertRaisesRegex(ValueError,
"identity already claimed"):
n.register_identity("client", "pc")
self.assertFalse(cb.mock_calls)
self.assertSetEqual(
{
("client", "pc", None, None),
},
set(n.iter_identities(unittest.mock.sentinel.stanza))
)
def test_register_identity_with_names(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_identity(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "test identity",
structs.LanguageTag.fromstr("de"): "Testidentität",
}
)
cb.assert_called_with()
self.assertSetEqual(
{
("client", "pc",
structs.LanguageTag.fromstr("en"), "test identity"),
("client", "pc",
structs.LanguageTag.fromstr("de"), "Testidentität"),
},
set(n.iter_identities(unittest.mock.sentinel.stanza))
)
def test_set_identity_names_updates_text_and_emits_cb(self):
n = disco_service.Node()
n.register_identity(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "default identity",
structs.LanguageTag.fromstr("de"): "Standardidentität",
}
)
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.set_identity_names(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "test identity",
structs.LanguageTag.fromstr("de"): "Testidentität",
}
)
cb.assert_called_with()
self.assertSetEqual(
{
("client", "pc",
structs.LanguageTag.fromstr("en"), "test identity"),
("client", "pc",
structs.LanguageTag.fromstr("de"), "Testidentität"),
},
set(n.iter_identities(unittest.mock.sentinel.stanza))
)
def test_set_identity_names_rejects_if_identity_not_registered(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
with self.assertRaisesRegex(
ValueError,
r"identity not registered"):
n.set_identity_names(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "test identity",
structs.LanguageTag.fromstr("de"): "Testidentität",
}
)
cb.assert_not_called()
def test_unregister_identity_prohibits_removal_of_last_identity(self):
n = disco_service.Node()
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.register_identity(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "test identity",
structs.LanguageTag.fromstr("de"): "Testidentität",
}
)
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
with self.assertRaisesRegex(ValueError,
"cannot remove last identity"):
n.unregister_identity(
"client", "pc",
)
self.assertFalse(cb.mock_calls)
def test_unregister_identity_prohibits_removal_of_undeclared_identity(
self):
n = disco_service.Node()
n.register_identity(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "test identity",
structs.LanguageTag.fromstr("de"): "Testidentität",
}
)
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
with self.assertRaises(KeyError):
n.unregister_identity("foo", "bar")
self.assertFalse(cb.mock_calls)
def test_unregister_identity_removes_identity(self):
n = disco_service.Node()
n.register_identity(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "test identity",
structs.LanguageTag.fromstr("de"): "Testidentität",
}
)
n.register_identity(
"foo", "bar"
)
self.assertSetEqual(
{
("client", "pc",
structs.LanguageTag.fromstr("en"), "test identity"),
("client", "pc",
structs.LanguageTag.fromstr("de"), "Testidentität"),
("foo", "bar", None, None),
},
set(n.iter_identities(unittest.mock.sentinel.stanza))
)
cb = unittest.mock.Mock()
n.on_info_changed.connect(cb)
n.unregister_identity("foo", "bar")
cb.assert_called_with()
self.assertSetEqual(
{
("client", "pc",
structs.LanguageTag.fromstr("en"), "test identity"),
("client", "pc",
structs.LanguageTag.fromstr("de"), "Testidentität"),
},
set(n.iter_identities(unittest.mock.sentinel.stanza))
)
def test_iter_items_works_without_argument(self):
n = disco_service.Node()
self.assertSequenceEqual(
list(n.iter_items()),
[]
)
def test_as_info_xso(self):
n = disco_service.Node()
features = [
"http://jabber.org/protocol/disco#info",
unittest.mock.sentinel.f1,
unittest.mock.sentinel.f2,
unittest.mock.sentinel.f3,
]
identities = [
("cat1", "t1",
structs.LanguageTag.fromstr("lang-a"), "name11"),
("cat1", "t1",
structs.LanguageTag.fromstr("lang-b"), "name12"),
("cat2", "t2", None, "name2"),
("cat3", "t3", None, None),
]
with contextlib.ExitStack() as stack:
iter_features = stack.enter_context(
unittest.mock.patch.object(n, "iter_features")
)
iter_features.return_value = iter(features)
iter_identities = stack.enter_context(
unittest.mock.patch.object(n, "iter_identities")
)
iter_identities.return_value = iter(identities)
iter_items = stack.enter_context(
unittest.mock.patch.object(n, "iter_items")
)
result = n.as_info_xso()
self.assertIsInstance(
result,
disco_xso.InfoQuery,
)
iter_items.assert_not_called()
iter_features.assert_called_once_with(None)
iter_identities.assert_called_once_with(None)
self.assertSetEqual(
result.features,
set(features),
)
self.assertCountEqual(
[
(i.category, i.type_, i.lang, i.name)
for i in result.identities
],
identities,
)
def test_as_info_xso_with_stanza(self):
n = disco_service.Node()
features = [
"http://jabber.org/protocol/disco#info",
unittest.mock.sentinel.f1,
]
identities = [
("cat1", "t1",
structs.LanguageTag.fromstr("lang-a"), "name11"),
("cat1", "t1",
structs.LanguageTag.fromstr("lang-b"), "name12"),
]
with contextlib.ExitStack() as stack:
iter_features = stack.enter_context(
unittest.mock.patch.object(n, "iter_features")
)
iter_features.return_value = iter(features)
iter_identities = stack.enter_context(
unittest.mock.patch.object(n, "iter_identities")
)
iter_identities.return_value = iter(identities)
iter_items = stack.enter_context(
unittest.mock.patch.object(n, "iter_items")
)
result = n.as_info_xso(unittest.mock.sentinel.stanza)
self.assertIsInstance(
result,
disco_xso.InfoQuery,
)
iter_items.assert_not_called()
iter_features.assert_called_once_with(unittest.mock.sentinel.stanza)
iter_identities.assert_called_once_with(unittest.mock.sentinel.stanza)
self.assertSetEqual(
result.features,
set(features),
)
self.assertCountEqual(
[
(i.category, i.type_, i.lang, i.name)
for i in result.identities
],
identities,
)
class TestStaticNode(unittest.TestCase):
def setUp(self):
self.n = disco_service.StaticNode()
def test_is_Node(self):
self.assertIsInstance(self.n, disco_service.Node)
def test_add_items(self):
item1 = disco_xso.Item(TEST_JID.replace(localpart="foo"))
item2 = disco_xso.Item(TEST_JID.replace(localpart="bar"))
self.n.items.append(item1)
self.n.items.append(item2)
self.assertSequenceEqual(
[
item1,
item2
],
list(self.n.iter_items(unittest.mock.sentinel.jid))
)
def test_iter_items_works_without_argument(self):
self.assertSequenceEqual(
list(self.n.iter_items()),
[]
)
def test_clone(self):
other_node = unittest.mock.Mock([
"iter_features",
"iter_identities",
"iter_items",
])
features = [
"http://jabber.org/protocol/disco#info",
unittest.mock.sentinel.f1,
unittest.mock.sentinel.f2,
unittest.mock.sentinel.f3,
]
identities = [
(unittest.mock.sentinel.cat1, unittest.mock.sentinel.t1,
unittest.mock.sentinel.lang11, unittest.mock.sentinel.name11),
(unittest.mock.sentinel.cat1, unittest.mock.sentinel.t1,
unittest.mock.sentinel.lang12, unittest.mock.sentinel.name12),
(unittest.mock.sentinel.cat2, unittest.mock.sentinel.t2,
None, unittest.mock.sentinel.name2),
(unittest.mock.sentinel.cat3, unittest.mock.sentinel.t3,
None, None),
]
items = [
unittest.mock.sentinel.item1,
unittest.mock.sentinel.item2,
]
other_node.iter_features.return_value = iter(features)
other_node.iter_identities.return_value = iter(identities)
other_node.iter_items.return_value = iter(items)
n = disco_service.StaticNode.clone(other_node)
self.assertIsInstance(n, disco_service.StaticNode)
other_node.iter_features.assert_called_once_with()
other_node.iter_identities.assert_called_once_with()
other_node.iter_items.assert_called_once_with()
self.assertCountEqual(
features,
list(n.iter_features()),
)
self.assertCountEqual(
identities,
list(n.iter_identities()),
)
self.assertCountEqual(
items,
list(n.iter_items()),
)
class TestDiscoServer(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.s = disco_service.DiscoServer(self.cc)
self.cc.reset_mock()
self.request_iq = stanza.IQ(
structs.IQType.GET,
from_=structs.JID.fromstr("user@foo.example/res1"),
to=structs.JID.fromstr("user@bar.example/res2"))
self.request_iq.autoset_id()
self.request_iq.payload = disco_xso.InfoQuery()
self.request_items_iq = stanza.IQ(
structs.IQType.GET,
from_=structs.JID.fromstr("user@foo.example/res1"),
to=structs.JID.fromstr("user@bar.example/res2"))
self.request_items_iq.autoset_id()
self.request_items_iq.payload = disco_xso.ItemsQuery()
def test_is_Service_subclass(self):
self.assertTrue(issubclass(
disco_service.DiscoServer,
service.Service))
def test_setup(self):
cc = make_connected_client()
s = disco_service.DiscoServer(cc)
self.assertCountEqual(
[
unittest.mock.call.stream.register_iq_request_handler(
structs.IQType.GET,
disco_xso.InfoQuery,
s.handle_info_request,
with_send_reply=False,
),
unittest.mock.call.stream.register_iq_request_handler(
structs.IQType.GET,
disco_xso.ItemsQuery,
s.handle_items_request,
with_send_reply=False,
)
],
cc.mock_calls
)
def test_shutdown(self):
run_coroutine(self.s.shutdown())
self.assertCountEqual(
[
unittest.mock.call.stream.unregister_iq_request_handler(
structs.IQType.GET,
disco_xso.InfoQuery
),
unittest.mock.call.stream.unregister_iq_request_handler(
structs.IQType.GET,
disco_xso.ItemsQuery
),
],
self.cc.mock_calls
)
def test_handle_info_request_is_decorated(self):
self.assertTrue(
service.is_iq_handler(
structs.IQType.GET,
disco_xso.InfoQuery,
disco_service.DiscoServer.handle_info_request,
)
)
def test_handle_items_request_is_decorated(self):
self.assertTrue(
service.is_iq_handler(
structs.IQType.GET,
disco_xso.ItemsQuery,
disco_service.DiscoServer.handle_items_request,
)
)
def test_default_response(self):
response = run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertSetEqual(
{namespaces.xep0030_info},
response.features,
)
self.assertSetEqual(
{
("client", "bot",
"aioxmpp default identity",
structs.LanguageTag.fromstr("en")),
},
set((item.category, item.type_,
item.name, item.lang) for item in response.identities)
)
self.assertFalse(response.node)
def test_nonexistant_node_response(self):
self.request_iq.payload.node = "foobar"
with self.assertRaises(errors.XMPPModifyError) as ctx:
run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertEqual(
errors.ErrorCondition.ITEM_NOT_FOUND,
ctx.exception.condition
)
def test_register_feature_produces_it_in_response(self):
self.s.register_feature("uri:foo")
self.s.register_feature("uri:bar")
response = run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertSetEqual(
{"uri:foo", "uri:bar", namespaces.xep0030_info},
response.features,
)
def test_unregister_feature_removes_it_from_response(self):
self.s.register_feature("uri:foo")
self.s.register_feature("uri:bar")
self.s.unregister_feature("uri:bar")
response = run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertSetEqual(
{"uri:foo", namespaces.xep0030_info},
response.features
)
def test_unregister_feature_raises_KeyError_if_feature_has_not_been_registered(self):
with self.assertRaisesRegex(KeyError, "uri:foo"):
self.s.unregister_feature("uri:foo")
def test_unregister_feature_disallows_unregistering_disco_info_feature(self):
with self.assertRaises(KeyError):
self.s.unregister_feature(namespaces.xep0030_info)
def test_register_identity_produces_it_in_response(self):
self.s.register_identity(
"client", "pc"
)
self.s.register_identity(
"hierarchy", "branch"
)
response = run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertSetEqual(
{
("client", "pc", None, None),
("hierarchy", "branch", None, None),
("client", "bot", "aioxmpp default identity",
structs.LanguageTag.fromstr("en")),
},
set((item.category, item.type_,
item.name, item.lang) for item in response.identities)
)
def test_unregister_identity_removes_it_from_response(self):
self.s.register_identity(
"client", "pc"
)
self.s.unregister_identity("client", "bot")
self.s.register_identity(
"hierarchy", "branch"
)
self.s.unregister_identity("hierarchy", "branch")
response = run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertSetEqual(
{
("client", "pc", None, None),
},
set((item.category, item.type_,
item.name, item.lang) for item in response.identities)
)
def test_unregister_identity_raises_KeyError_if_not_registered(self):
with self.assertRaisesRegex(KeyError, r"\('client', 'pc'\)"):
self.s.unregister_identity("client", "pc")
def test_register_identity_with_names(self):
self.s.register_identity(
"client", "pc",
names={
structs.LanguageTag.fromstr("en"): "test identity",
structs.LanguageTag.fromstr("de"): "Testidentität",
}
)
self.s.unregister_identity("client", "bot")
response = run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertSetEqual(
{
("client", "pc",
"test identity",
structs.LanguageTag.fromstr("en")),
("client", "pc",
"Testidentität",
structs.LanguageTag.fromstr("de")),
},
set((item.category, item.type_,
item.name, item.lang) for item in response.identities)
)
def test_register_identity_disallows_duplicates(self):
self.s.register_identity("client", "pc")
with self.assertRaisesRegex(ValueError, "identity already claimed"):
self.s.register_identity("client", "pc")
def test_register_feature_disallows_duplicates(self):
self.s.register_feature("uri:foo")
with self.assertRaisesRegex(ValueError, "feature already claimed"):
self.s.register_feature("uri:foo")
with self.assertRaisesRegex(ValueError, "feature already claimed"):
self.s.register_feature(namespaces.xep0030_info)
def test_mount_node_produces_response(self):
node = disco_service.StaticNode()
node.register_identity("hierarchy", "leaf")
self.s.mount_node("foo", node)
self.request_iq.payload.node = "foo"
response = run_coroutine(self.s.handle_info_request(self.request_iq))
self.assertSetEqual(
{
("hierarchy", "leaf", None, None),
},
set((item.category, item.type_,
item.name, item.lang) for item in response.identities)
)
def test_mount_node_without_identity_produces_item_not_found(self):
node = disco_service.StaticNode()
self.s.mount_node("foo", node)
self.request_iq.payload.node = "foo"
with self.assertRaises(errors.XMPPModifyError):
run_coroutine(self.s.handle_info_request(self.request_iq))
def test_unmount_node(self):
node = disco_service.StaticNode()
node.register_identity("hierarchy", "leaf")
self.s.mount_node("foo", node)
self.s.unmount_node("foo")
self.request_iq.payload.node = "foo"
with self.assertRaises(errors.XMPPModifyError):
run_coroutine(self.s.handle_info_request(self.request_iq))
def test_default_items_response(self):
response = run_coroutine(
self.s.handle_items_request(self.request_items_iq)
)
self.assertIsInstance(response, disco_xso.ItemsQuery)
self.assertSequenceEqual(
[],
response.items
)
def test_items_query_returns_item_not_found_for_unknown_node(self):
self.request_items_iq.payload.node = "foobar"
with self.assertRaises(errors.XMPPModifyError):
run_coroutine(
self.s.handle_items_request(self.request_items_iq)
)
def test_items_query_returns_items_of_mounted_node(self):
item1 = disco_xso.Item(TEST_JID.replace(localpart="foo"))
item2 = disco_xso.Item(TEST_JID.replace(localpart="bar"))
node = disco_service.StaticNode()
node.register_identity("hierarchy", "leaf")
node.items.append(item1)
node.items.append(item2)
self.s.mount_node("foo", node)
self.request_items_iq.payload.node = "foo"
response = run_coroutine(
self.s.handle_items_request(self.request_items_iq)
)
self.assertSequenceEqual(
[item1, item2],
response.items
)
def test_items_query_forwards_stanza(self):
node = unittest.mock.Mock()
node.iter_items.return_value = iter([])
self.s.mount_node("foo", node)
self.request_items_iq.payload.node = "foo"
run_coroutine(
self.s.handle_items_request(self.request_items_iq)
)
node.iter_items.assert_called_once_with(
self.request_items_iq
)
def test_info_query_forwards_stanza(self):
node = unittest.mock.Mock()
self.s.mount_node("foo", node)
self.request_iq.payload.node = "foo"
result = run_coroutine(
self.s.handle_info_request(self.request_iq)
)
node.as_info_xso.assert_called_once_with(
self.request_iq
)
self.assertEqual(result, node.as_info_xso())
def test_info_query_sets_node(self):
node = unittest.mock.Mock()
self.s.mount_node("foo", node)
self.request_iq.payload.node = "foo"
result = run_coroutine(
self.s.handle_info_request(self.request_iq)
)
node.as_info_xso.assert_called_once_with(
self.request_iq
)
self.assertEqual(result.node, "foo")
self.assertEqual(result, node.as_info_xso())
class TestDiscoClient(unittest.TestCase):
def setUp(self):
self.cc = make_connected_client()
self.s = disco_service.DiscoClient(self.cc)
self.cc.reset_mock()
self.request_iq = stanza.IQ(
structs.IQType.GET,
from_=structs.JID.fromstr("user@foo.example/res1"),
to=structs.JID.fromstr("user@bar.example/res2"))
self.request_iq.autoset_id()
self.request_iq.payload = disco_xso.InfoQuery()
self.request_items_iq = stanza.IQ(
structs.IQType.GET,
from_=structs.JID.fromstr("user@foo.example/res1"),
to=structs.JID.fromstr("user@bar.example/res2"))
self.request_items_iq.autoset_id()
self.request_items_iq.payload = disco_xso.ItemsQuery()
def test_is_Service_subclass(self):
self.assertTrue(issubclass(
disco_service.DiscoClient,
service.Service))
def test_send_and_decode_info_query(self):
to = structs.JID.fromstr("user@foo.example/res1")
node = "foobar"
response = disco_xso.InfoQuery()
self.cc.send.return_value = response
result = run_coroutine(
self.s.send_and_decode_info_query(to, node)
)
self.assertEqual(result, response)
self.assertEqual(
1,
len(self.cc.send.mock_calls)
)
call, = self.cc.send.mock_calls
# call[1] are args
request_iq, = call[1]
self.assertEqual(
to,
request_iq.to
)
self.assertEqual(
structs.IQType.GET,
request_iq.type_
)
self.assertIsInstance(request_iq.payload, disco_xso.InfoQuery)
self.assertFalse(request_iq.payload.features)
self.assertFalse(request_iq.payload.identities)
self.assertIs(request_iq.payload.node, node)
def test_uses_LRUDict(self):
with contextlib.ExitStack() as stack:
LRUDict = stack.enter_context(unittest.mock.patch(
"aioxmpp.cache.LRUDict",
new=unittest.mock.MagicMock()
))
self.s = disco_service.DiscoClient(self.cc)
self.assertCountEqual(
LRUDict.mock_calls,
[
unittest.mock.call(),
unittest.mock.call(),
]
)
LRUDict().__getitem__.side_effect = KeyError
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
run_coroutine(
self.s.query_info(unittest.mock.sentinel.jid,
node=unittest.mock.sentinel.node)
)
LRUDict().__setitem__.assert_called_once_with(
(unittest.mock.sentinel.jid, unittest.mock.sentinel.node),
unittest.mock.ANY,
)
LRUDict().__setitem__.reset_mock()
run_coroutine(
self.s.query_items(TEST_JID,
node="some node")
)
LRUDict().__setitem__.assert_called_once_with(
(TEST_JID, "some node"),
unittest.mock.ANY,
)
def test_info_cache_size(self):
self.assertEqual(
self.s.info_cache_size,
10000,
)
self.assertEqual(
self.s.info_cache_size,
self.s._info_pending.maxsize,
)
def test_info_cache_size_is_settable(self):
self.s.info_cache_size = 5
self.assertEqual(
self.s.info_cache_size,
5,
)
self.assertEqual(
self.s._info_pending.maxsize,
self.s.info_cache_size,
)
def test_items_cache_size(self):
self.assertEqual(
self.s.items_cache_size,
100,
)
self.assertEqual(
self.s.items_cache_size,
self.s._items_pending.maxsize,
)
def test_items_cache_size_is_settable(self):
self.s.items_cache_size = 5
self.assertEqual(
self.s.items_cache_size,
5,
)
self.assertEqual(
self.s._items_pending.maxsize,
self.s.items_cache_size,
)
def test_query_info(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = {}
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.return_value = response
result = run_coroutine(
self.s.query_info(to)
)
send_and_decode.assert_called_with(to, None)
self.assertIs(response, result)
def test_query_response_leads_to_signal_emission(self):
handler = unittest.mock.Mock()
handler.return_value = None
to = structs.JID.fromstr("user@foo.example/res1")
response = {}
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.return_value = response
self.s.on_info_result.connect(handler)
run_coroutine(
self.s.query_info(to)
)
handler.assert_called_with(to, None, response)
def test_query_response_for_node_leads_to_signal_emission(self):
handler = unittest.mock.Mock()
handler.return_value = None
to = structs.JID.fromstr("user@foo.example/res1")
response = {}
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.return_value = response
self.s.on_info_result.connect(handler)
run_coroutine(
self.s.query_info(to, node="foo")
)
handler.assert_called_with(to, "foo", response)
def test_query_info_with_node(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = {}
with self.assertRaises(TypeError):
self.s.query_info(to, "foobar")
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.return_value = response
result = run_coroutine(
self.s.query_info(to, node="foobar")
)
send_and_decode.assert_called_with(to, "foobar")
self.assertIs(result, response)
def test_query_info_caches(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = {}
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.return_value = response
result1 = run_coroutine(
self.s.query_info(to, node="foobar")
)
result2 = run_coroutine(
self.s.query_info(to, node="foobar")
)
self.assertIs(result1, response)
self.assertIs(result2, response)
self.assertEqual(
1,
len(send_and_decode.mock_calls)
)
def test_query_info_does_not_cache_if_no_cache_is_false(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = {}
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.return_value = response
result1 = run_coroutine(
self.s.query_info(to, node="foobar", no_cache=True)
)
result2 = run_coroutine(
self.s.query_info(to, node="foobar")
)
self.assertIs(result1, response)
self.assertIs(result2, response)
self.assertEqual(
2,
len(send_and_decode.mock_calls)
)
def test_query_info_with_no_cache_uses_cached_result(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = {}
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.return_value = response
result1 = run_coroutine(
self.s.query_info(to, node="foobar")
)
result2 = run_coroutine(
self.s.query_info(to, node="foobar", no_cache=True)
)
self.assertIs(result1, response)
self.assertIs(result2, response)
self.assertEqual(
1,
len(send_and_decode.mock_calls)
)
def test_query_info_reraises_and_aliases_exception(self):
to = structs.JID.fromstr("user@foo.example/res1")
ncall = 0
async def mock(*args, **kwargs):
nonlocal ncall
ncall += 1
if ncall == 1:
raise errors.XMPPCancelError(
condition=errors.ErrorCondition.FEATURE_NOT_IMPLEMENTED
)
else:
raise ConnectionError()
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=mock):
task1 = asyncio.ensure_future(
self.s.query_info(to, node="foobar")
)
task2 = asyncio.ensure_future(
self.s.query_info(to, node="foobar")
)
with self.assertRaises(errors.XMPPCancelError):
run_coroutine(task1)
with self.assertRaises(errors.XMPPCancelError):
run_coroutine(task2)
def test_query_info_reraises_but_does_not_cache_exception(self):
to = structs.JID.fromstr("user@foo.example/res1")
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
send_and_decode.side_effect = errors.XMPPCancelError(
condition=errors.ErrorCondition.FEATURE_NOT_IMPLEMENTED
)
with self.assertRaises(errors.XMPPCancelError):
run_coroutine(
self.s.query_info(to, node="foobar")
)
send_and_decode.side_effect = ConnectionError()
with self.assertRaises(ConnectionError):
run_coroutine(
self.s.query_info(to, node="foobar")
)
def test_query_info_cache_override(self):
to = structs.JID.fromstr("user@foo.example/res1")
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
response1 = {}
send_and_decode.return_value = response1
result1 = run_coroutine(
self.s.query_info(to, node="foobar")
)
response2 = {}
send_and_decode.return_value = response2
result2 = run_coroutine(
self.s.query_info(to, node="foobar", require_fresh=True)
)
self.assertIs(result1, response1)
self.assertIs(result2, response2)
self.assertEqual(
2,
len(send_and_decode.mock_calls)
)
def test_flush_cache_clears_info_cache(self):
to = structs.JID.fromstr("user@foo.example/res1")
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
response1 = {}
send_and_decode.return_value = response1
result1 = run_coroutine(
self.s.query_info(to, node="foobar")
)
self.s.flush_cache()
response2 = {}
send_and_decode.return_value = response2
result2 = run_coroutine(
self.s.query_info(to, node="foobar")
)
self.assertIs(result1, response1)
self.assertIs(result2, response2)
self.assertEqual(
2,
len(send_and_decode.mock_calls)
)
def test_query_info_cache_clears_on_disconnect(self):
to = structs.JID.fromstr("user@foo.example/res1")
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
response1 = {}
send_and_decode.return_value = response1
result1 = run_coroutine(
self.s.query_info(to, node="foobar")
)
self.cc.on_stream_destroyed()
response2 = {}
send_and_decode.return_value = response2
result2 = run_coroutine(
self.s.query_info(to, node="foobar")
)
self.assertIs(result1, response1)
self.assertIs(result2, response2)
self.assertEqual(
2,
len(send_and_decode.mock_calls)
)
def test_query_info_timeout(self):
to = structs.JID.fromstr("user@foo.example/res1")
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
response = {}
send_and_decode.delay = 1
send_and_decode.return_value = response
with self.assertRaises(TimeoutError):
result = run_coroutine(
self.s.query_info(to, timeout=0.01)
)
self.assertSequenceEqual(
[
unittest.mock.call(to, None),
],
send_and_decode.mock_calls
)
def test_query_info_deduplicate_requests(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.InfoQuery()
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
response = {}
send_and_decode.return_value = response
result = run_coroutine(
asyncio.gather(
self.s.query_info(to, timeout=10),
self.s.query_info(to, timeout=10),
)
)
self.assertIs(result[0], response)
self.assertIs(result[1], response)
self.assertSequenceEqual(
[
unittest.mock.call(to, None),
],
send_and_decode.mock_calls
)
def test_query_info_transparent_deduplication_when_cancelled(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.InfoQuery()
with unittest.mock.patch.object(
self.s,
"send_and_decode_info_query",
new=CoroutineMock()) as send_and_decode:
response = {}
send_and_decode.return_value = response
send_and_decode.delay = 0.1
q1 = asyncio.ensure_future(self.s.query_info(to))
q2 = asyncio.ensure_future(self.s.query_info(to))
run_coroutine(asyncio.sleep(0.05))
q1.cancel()
result = run_coroutine(q2)
self.assertIs(result, response)
self.assertSequenceEqual(
[
unittest.mock.call(to, None),
unittest.mock.call(to, None),
],
send_and_decode.mock_calls
)
def test_query_items(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.ItemsQuery()
self.cc.send.return_value = response
result = run_coroutine(
self.s.query_items(to)
)
self.assertIs(result, response)
self.assertEqual(
1,
len(self.cc.send.mock_calls)
)
call, = self.cc.send.mock_calls
# call[1] are args
request_iq, = call[1]
self.assertEqual(
to,
request_iq.to
)
self.assertEqual(
structs.IQType.GET,
request_iq.type_
)
self.assertIsInstance(request_iq.payload, disco_xso.ItemsQuery)
self.assertFalse(request_iq.payload.items)
self.assertIsNone(request_iq.payload.node)
def test_query_items_with_node(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.ItemsQuery()
self.cc.send.return_value = response
with self.assertRaises(TypeError):
self.s.query_items(to, "foobar")
result = run_coroutine(
self.s.query_items(to, node="foobar")
)
self.assertIs(result, response)
self.assertEqual(
1,
len(self.cc.send.mock_calls)
)
call, = self.cc.send.mock_calls
# call[1] are args
request_iq, = call[1]
self.assertEqual(
to,
request_iq.to
)
self.assertEqual(
structs.IQType.GET,
request_iq.type_
)
self.assertIsInstance(request_iq.payload, disco_xso.ItemsQuery)
self.assertFalse(request_iq.payload.items)
self.assertEqual("foobar", request_iq.payload.node)
def test_query_items_caches(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.ItemsQuery()
self.cc.send.return_value = response
with self.assertRaises(TypeError):
self.s.query_items(to, "foobar")
result1 = run_coroutine(
self.s.query_items(to, node="foobar")
)
result2 = run_coroutine(
self.s.query_items(to, node="foobar")
)
self.assertIs(result1, response)
self.assertIs(result2, response)
self.assertEqual(
1,
len(self.cc.send.mock_calls)
)
def test_query_items_reraises_and_aliases_exception(self):
to = structs.JID.fromstr("user@foo.example/res1")
ncall = 0
async def mock(*args, **kwargs):
nonlocal ncall
ncall += 1
if ncall == 1:
raise errors.XMPPCancelError(
condition=errors.ErrorCondition.FEATURE_NOT_IMPLEMENTED
)
else:
raise ConnectionError()
with unittest.mock.patch.object(
self.cc,
"send",
new=mock):
task1 = asyncio.ensure_future(
self.s.query_info(to, node="foobar")
)
task2 = asyncio.ensure_future(
self.s.query_info(to, node="foobar")
)
with self.assertRaises(errors.XMPPCancelError):
run_coroutine(task1)
with self.assertRaises(errors.XMPPCancelError):
run_coroutine(task2)
def test_query_info_reraises_but_does_not_cache_exception(self):
to = structs.JID.fromstr("user@foo.example/res1")
self.cc.send.side_effect = \
errors.XMPPCancelError(
condition=errors.ErrorCondition.FEATURE_NOT_IMPLEMENTED,
)
with self.assertRaises(errors.XMPPCancelError):
run_coroutine(
self.s.query_items(to, node="foobar")
)
self.cc.send.side_effect = \
ConnectionError()
with self.assertRaises(ConnectionError):
run_coroutine(
self.s.query_items(to, node="foobar")
)
def test_query_items_cache_override(self):
to = structs.JID.fromstr("user@foo.example/res1")
response1 = disco_xso.ItemsQuery()
self.cc.send.return_value = response1
with self.assertRaises(TypeError):
self.s.query_items(to, "foobar")
result1 = run_coroutine(
self.s.query_items(to, node="foobar")
)
response2 = disco_xso.ItemsQuery()
self.cc.send.return_value = response2
result2 = run_coroutine(
self.s.query_items(to, node="foobar", require_fresh=True)
)
self.assertIs(result1, response1)
self.assertIs(result2, response2)
self.assertEqual(
2,
len(self.cc.send.mock_calls)
)
def test_flush_cache_clears_items_cache(self):
to = structs.JID.fromstr("user@foo.example/res1")
response1 = disco_xso.ItemsQuery()
self.cc.send.return_value = response1
with self.assertRaises(TypeError):
self.s.query_items(to, "foobar")
result1 = run_coroutine(
self.s.query_items(to, node="foobar")
)
response2 = disco_xso.ItemsQuery()
self.cc.send.return_value = response2
self.s.flush_cache()
result2 = run_coroutine(
self.s.query_items(to, node="foobar")
)
self.assertIs(result1, response1)
self.assertIs(result2, response2)
self.assertEqual(
2,
len(self.cc.send.mock_calls)
)
def test_query_items_cache_clears_on_disconnect(self):
to = structs.JID.fromstr("user@foo.example/res1")
response1 = disco_xso.ItemsQuery()
self.cc.send.return_value = response1
with self.assertRaises(TypeError):
self.s.query_items(to, "foobar")
result1 = run_coroutine(
self.s.query_items(to, node="foobar")
)
self.cc.on_stream_destroyed()
response2 = disco_xso.ItemsQuery()
self.cc.send.return_value = response2
result2 = run_coroutine(
self.s.query_items(to, node="foobar")
)
self.assertIs(result1, response1)
self.assertIs(result2, response2)
self.assertEqual(
2,
len(self.cc.send.mock_calls)
)
def test_query_items_timeout(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.ItemsQuery()
self.cc.send.delay = 1
self.cc.send.return_value = response
with self.assertRaises(TimeoutError):
result = run_coroutine(
self.s.query_items(to, timeout=0.01)
)
self.assertSequenceEqual(
[
unittest.mock.call(unittest.mock.ANY),
],
self.cc.send.mock_calls
)
def test_query_items_deduplicate_requests(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.ItemsQuery()
self.cc.send.return_value = response
result = run_coroutine(
asyncio.gather(
self.s.query_items(to, timeout=10),
self.s.query_items(to, timeout=10),
)
)
self.assertIs(result[0], response)
self.assertIs(result[1], response)
self.assertSequenceEqual(
[
unittest.mock.call(unittest.mock.ANY),
],
self.cc.send.mock_calls
)
def test_query_items_transparent_deduplication_when_cancelled(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.ItemsQuery()
self.cc.send.return_value = response
self.cc.send.delay = 0.1
q1 = asyncio.ensure_future(self.s.query_items(to))
q2 = asyncio.ensure_future(self.s.query_items(to))
run_coroutine(asyncio.sleep(0.05))
q1.cancel()
result = run_coroutine(q2)
self.assertIs(result, response)
self.assertSequenceEqual(
[
unittest.mock.call(unittest.mock.ANY),
unittest.mock.call(unittest.mock.ANY),
],
self.cc.send.mock_calls
)
def test_set_info_cache(self):
to = structs.JID.fromstr("user@foo.example/res1")
response = disco_xso.ItemsQuery()
self.s.set_info_cache(
to,
None,
response
)
other_response = disco_xso.InfoQuery()
self.cc.send.return_value = \
other_response
result = run_coroutine(self.s.query_info(to, node=None))
self.assertIs(result, response)
self.assertFalse(self.cc.stream.mock_calls)
def test_set_info_future(self):
to = structs.JID.fromstr("user@foo.example/res1")
fut = asyncio.Future()
self.s.set_info_future(
to,
None,
fut
)
request = asyncio.ensure_future(
self.s.query_info(to)
)
run_coroutine(asyncio.sleep(0))
self.assertFalse(request.done())
result = object()
fut.set_result(result)
self.assertIs(run_coroutine(request), result)
def test_set_info_future_stays_even_with_exception(self):
exc = ConnectionError()
to = structs.JID.fromstr("user@foo.example/res1")
fut = asyncio.Future()
self.s.set_info_future(
to,
None,
fut
)
request = asyncio.ensure_future(
self.s.query_info(to)
)
run_coroutine(asyncio.sleep(0))
self.assertFalse(request.done())
fut.set_exception(exc)
with self.assertRaises(Exception) as ctx:
run_coroutine(request)
self.assertIs(ctx.exception, exc)
class Testmount_as_node(unittest.TestCase):
def setUp(self):
self.pn = disco_service.mount_as_node(
unittest.mock.sentinel.mountpoint
)
self.instance = unittest.mock.Mock()
self.disco_server = unittest.mock.Mock()
self.instance.dependencies = {
disco_service.DiscoServer: self.disco_server
}
def tearDown(self):
del self.instance
del self.disco_server
del self.pn
def test_value_type(self):
self.assertIs(
self.pn.value_type,
type(None),
)
def test_mountpoint(self):
self.assertEqual(
self.pn.mountpoint,
unittest.mock.sentinel.mountpoint,
)
def test_required_dependencies(self):
self.assertSetEqual(
set(self.pn.required_dependencies),
{disco_service.DiscoServer},
)
def test_contextmanager(self):
cm = self.pn.init_cm(self.instance)
self.disco_server.mount_node.assert_not_called()
cm.__enter__()
self.disco_server.mount_node.assert_called_once_with(
unittest.mock.sentinel.mountpoint,
self.instance,
)
self.disco_server.unmount_node.assert_not_called()
cm.__exit__(None, None, None)
self.disco_server.unmount_node.assert_called_once_with(
unittest.mock.sentinel.mountpoint,
)
def test_contextmanager_is_exception_safe(self):
cm = self.pn.init_cm(self.instance)
self.disco_server.mount_node.assert_not_called()
cm.__enter__()
self.disco_server.mount_node.assert_called_once_with(
unittest.mock.sentinel.mountpoint,
self.instance,
)
self.disco_server.unmount_node.assert_not_called()
try:
raise Exception()
except: # NOQA
cm.__exit__(*sys.exc_info())
self.disco_server.unmount_node.assert_called_once_with(
unittest.mock.sentinel.mountpoint,
)
class TestRegisteredFeature(unittest.TestCase):
def setUp(self):
self.s = unittest.mock.Mock()
self.rf = disco_service.RegisteredFeature(
self.s,
unittest.mock.sentinel.feature,
)
def test_contextmanager(self):
self.s.register_feature.assert_not_called()
result = self.rf.__enter__()
self.assertIs(result, self.rf)
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.s.unregister_feature.assert_not_called()
result = self.rf.__exit__(None, None, None)
self.assertFalse(result)
self.s.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_contextmanager_is_exception_safe(self):
class FooException(Exception):
pass
self.s.register_feature.assert_not_called()
with self.assertRaises(FooException):
with self.rf:
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.s.unregister_feature.assert_not_called()
raise FooException()
self.s.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_feature(self):
self.assertEqual(
self.rf.feature,
unittest.mock.sentinel.feature,
)
def test_feature_is_not_writable(self):
with self.assertRaises(AttributeError):
self.rf.feature = self.rf.feature
def test_enabled(self):
self.assertFalse(self.rf.enabled)
def test_enabled_changes_with_cm_use(self):
with self.rf:
self.assertTrue(self.rf.enabled)
self.assertFalse(self.rf.enabled)
def test_setting_enabled_registers_feature(self):
self.assertFalse(self.rf.enabled)
self.s.register_feature.assert_not_called()
self.rf.enabled = True
self.assertTrue(self.rf.enabled)
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_setting_enabled_is_idempotent(self):
self.assertFalse(self.rf.enabled)
self.s.register_feature.assert_not_called()
self.rf.enabled = True
self.assertTrue(self.rf.enabled)
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.rf.enabled = True
self.assertTrue(self.rf.enabled)
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_clearing_enabled_unregisters_feature(self):
self.assertFalse(self.rf.enabled)
self.s.register_feature.assert_not_called()
self.rf.enabled = True
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.s.unregister_feature.assert_not_called()
self.rf.enabled = False
self.s.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_clearing_enabled_is_idempotent(self):
self.assertFalse(self.rf.enabled)
self.s.register_feature.assert_not_called()
self.rf.enabled = True
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.s.unregister_feature.assert_not_called()
self.rf.enabled = False
self.assertFalse(self.rf.enabled)
self.s.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.rf.enabled = False
self.s.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_clearing_enabled_while_in_cm_does_not_duplicate_unregister(self):
with self.rf:
self.rf.enabled = False
self.s.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.s.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_setting_enabled_before_entering_cm_does_not_duplicate_register(self): # NOQA
self.rf.enabled = True
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
with self.rf:
self.s.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
class Testregister_feature(unittest.TestCase):
def setUp(self):
self.pn = disco_service.register_feature(
unittest.mock.sentinel.feature
)
self.instance = unittest.mock.Mock()
self.disco_server = unittest.mock.Mock()
self.instance.dependencies = {
disco_service.DiscoServer: self.disco_server
}
def tearDown(self):
del self.instance
del self.disco_server
del self.pn
def test_value_type(self):
self.assertIs(
self.pn.value_type,
disco_service.RegisteredFeature,
)
def test_mountpoint(self):
self.assertEqual(
self.pn.feature,
unittest.mock.sentinel.feature,
)
def test_required_dependencies(self):
self.assertSetEqual(
set(self.pn.required_dependencies),
{disco_service.DiscoServer},
)
def test_init_cm_creates_RegisteredFeature(self):
with contextlib.ExitStack() as stack:
RegisteredFeature = stack.enter_context(
unittest.mock.patch("aioxmpp.disco.service.RegisteredFeature")
)
result = self.pn.init_cm(self.instance)
RegisteredFeature.assert_called_once_with(
self.disco_server,
unittest.mock.sentinel.feature,
)
self.assertEqual(result, RegisteredFeature())
def test_contextmanager(self):
cm = self.pn.init_cm(self.instance)
self.disco_server.register_feature.assert_not_called()
cm.__enter__()
self.disco_server.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.disco_server.unregister_feature.assert_not_called()
cm.__exit__(None, None, None)
self.disco_server.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
def test_contextmanager_is_exception_safe(self):
cm = self.pn.init_cm(self.instance)
self.disco_server.register_feature.assert_not_called()
cm.__enter__()
self.disco_server.register_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
self.disco_server.unregister_feature.assert_not_called()
try:
raise Exception()
except: # NOQA
cm.__exit__(*sys.exc_info())
self.disco_server.unregister_feature.assert_called_once_with(
unittest.mock.sentinel.feature,
)
tests/disco/test_xso.py 0000664 0000000 0000000 00000043324 14160146213 0015547 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import unittest
import aioxmpp.disco.xso as disco_xso
import aioxmpp.forms.xso as forms_xso
import aioxmpp.structs as structs
import aioxmpp.stanza as stanza
import aioxmpp.xso as xso
import aioxmpp.xso.model as xso_model
from aioxmpp.utils import namespaces
class TestNamespaces(unittest.TestCase):
def test_info_namespace(self):
self.assertEqual(
"http://jabber.org/protocol/disco#info",
namespaces.xep0030_info
)
def test_items_namespace(self):
self.assertEqual(
"http://jabber.org/protocol/disco#items",
namespaces.xep0030_items
)
class TestIdentity(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(disco_xso.Identity, xso.XSO))
def test_tag(self):
self.assertEqual(
(namespaces.xep0030_info, "identity"),
disco_xso.Identity.TAG
)
def test_category_attr(self):
self.assertIsInstance(
disco_xso.Identity.category,
xso.Attr
)
self.assertEqual(
(None, "category"),
disco_xso.Identity.category.tag
)
self.assertIs(
xso.NO_DEFAULT,
disco_xso.Identity.category.default
)
def test_type_attr(self):
self.assertIsInstance(
disco_xso.Identity.type_,
xso.Attr
)
self.assertEqual(
(None, "type"),
disco_xso.Identity.type_.tag
)
self.assertIs(
xso.NO_DEFAULT,
disco_xso.Identity.type_.default
)
def test_name_attr(self):
self.assertIsInstance(
disco_xso.Identity.name,
xso.Attr
)
self.assertEqual(
(None, "name"),
disco_xso.Identity.name.tag
)
self.assertIs(disco_xso.Identity.name.default, None)
def test_lang_attr(self):
self.assertIsInstance(
disco_xso.Identity.lang,
xso.LangAttr
)
self.assertIsNone(
disco_xso.Identity.lang.default
)
def test_init(self):
ident = disco_xso.Identity()
self.assertEqual("client", ident.category)
self.assertEqual("bot", ident.type_)
self.assertIsNone(ident.name)
self.assertIsNone(ident.lang)
ident = disco_xso.Identity(
category="account",
type_="anonymous",
name="Foobar",
lang=structs.LanguageTag.fromstr("de")
)
self.assertEqual("account", ident.category)
self.assertEqual("anonymous", ident.type_)
self.assertEqual("Foobar", ident.name)
self.assertEqual(structs.LanguageTag.fromstr("DE"), ident.lang)
def test_equality(self):
ident1 = disco_xso.Identity()
self.assertEqual("client", ident1.category)
self.assertEqual("bot", ident1.type_)
self.assertIsNone(ident1.name)
self.assertIsNone(ident1.lang)
ident2 = disco_xso.Identity()
self.assertEqual("client", ident2.category)
self.assertEqual("bot", ident2.type_)
self.assertIsNone(ident2.name)
self.assertIsNone(ident2.lang)
self.assertTrue(ident1 == ident2)
self.assertFalse(ident1 != ident2)
ident1.category = "foo"
self.assertFalse(ident1 == ident2)
self.assertTrue(ident1 != ident2)
ident2.category = "foo"
self.assertTrue(ident1 == ident2)
self.assertFalse(ident1 != ident2)
ident1.type_ = "bar"
self.assertFalse(ident1 == ident2)
self.assertTrue(ident1 != ident2)
ident2.type_ = "bar"
self.assertTrue(ident1 == ident2)
self.assertFalse(ident1 != ident2)
ident1.name = "baz"
self.assertFalse(ident1 == ident2)
self.assertTrue(ident1 != ident2)
ident2.name = "baz"
self.assertTrue(ident1 == ident2)
self.assertFalse(ident1 != ident2)
ident1.lang = structs.LanguageTag.fromstr("en")
self.assertFalse(ident1 == ident2)
self.assertTrue(ident1 != ident2)
ident2.lang = structs.LanguageTag.fromstr("en")
self.assertTrue(ident1 == ident2)
self.assertFalse(ident1 != ident2)
def test_equality_is_robust_against_other_data_types(self):
ident1 = disco_xso.Identity()
self.assertEqual("client", ident1.category)
self.assertEqual("bot", ident1.type_)
self.assertIsNone(ident1.name)
self.assertIsNone(ident1.lang)
self.assertFalse(ident1 == None) # NOQA
self.assertFalse(ident1 == 1)
self.assertFalse(ident1 == "foo")
self.assertTrue(ident1 != None) # NOQA
self.assertTrue(ident1 != 1)
self.assertTrue(ident1 != "foo")
class TestFeature(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(disco_xso.Feature, xso.XSO))
def test_tag(self):
self.assertEqual(
(namespaces.xep0030_info, "feature"),
disco_xso.Feature.TAG
)
def test_var_attr(self):
self.assertIsInstance(
disco_xso.Feature.var,
xso.Attr
)
self.assertEqual(
(None, "var"),
disco_xso.Feature.var.tag
)
self.assertIs(
xso.NO_DEFAULT,
disco_xso.Feature.var.default
)
def test_init(self):
with self.assertRaises(TypeError):
disco_xso.Feature()
f = disco_xso.Feature(var="foobar")
self.assertEqual("foobar", f.var)
class TestFeatureSet(unittest.TestCase):
def test_is_element_type(self):
self.assertTrue(issubclass(
disco_xso.FeatureSet,
xso.AbstractElementType
))
def setUp(self):
self.type_ = disco_xso.FeatureSet()
def tearDown(self):
del self.type_
def test_get_xso_types(self):
self.assertCountEqual(
self.type_.get_xso_types(),
[disco_xso.Feature]
)
def test_unpack(self):
item = disco_xso.Feature(var="foobar")
self.assertEqual(
"foobar",
self.type_.unpack(item)
)
def test_pack(self):
item = self.type_.pack("foobar")
self.assertIsInstance(
item,
disco_xso.Feature
)
self.assertEqual(
item.var,
"foobar"
)
class TestInfoQuery(unittest.TestCase):
def test_is_capturing_xso(self):
self.assertTrue(issubclass(disco_xso.InfoQuery, xso.CapturingXSO))
def test_tag(self):
self.assertEqual(
(namespaces.xep0030_info, "query"),
disco_xso.InfoQuery.TAG
)
def test_node_attr(self):
self.assertIsInstance(
disco_xso.InfoQuery.node,
xso.Attr
)
self.assertEqual(
(None, "node"),
disco_xso.InfoQuery.node.tag
)
self.assertIs(disco_xso.InfoQuery.node.default, None)
def test_identities_attr(self):
self.assertIsInstance(
disco_xso.InfoQuery.identities,
xso.ChildList
)
self.assertSetEqual(
{disco_xso.Identity},
set(disco_xso.InfoQuery.identities._classes)
)
def test_features_attr(self):
self.assertIsInstance(
disco_xso.InfoQuery.features,
xso.ChildValueList,
)
self.assertSetEqual(
{disco_xso.Feature},
set(disco_xso.InfoQuery.features._classes)
)
self.assertIsInstance(
disco_xso.InfoQuery.features.type_,
disco_xso.FeatureSet
)
self.assertIs(
disco_xso.InfoQuery.features.container_type,
set
)
def test_exts_attr(self):
self.assertIsInstance(
disco_xso.InfoQuery.exts,
xso.ChildList
)
self.assertSetEqual(
{forms_xso.Data},
set(disco_xso.InfoQuery.exts._classes)
)
def test_init(self):
iq = disco_xso.InfoQuery()
self.assertIsNone(iq.captured_events)
self.assertFalse(iq.features)
self.assertFalse(iq.identities)
self.assertIsNone(iq.node)
iq = disco_xso.InfoQuery(node="foobar",
features=(1, 2),
identities=(3,))
self.assertIsNone(iq.captured_events)
self.assertSetEqual(
{1, 2},
iq.features
)
self.assertIsInstance(iq.identities, xso_model.XSOList)
self.assertSequenceEqual(
[3],
iq.identities
)
self.assertEqual("foobar", iq.node)
def test_registered_at_IQ(self):
self.assertIn(
disco_xso.InfoQuery.TAG,
stanza.IQ.CHILD_MAP
)
def test_to_dict(self):
q = disco_xso.InfoQuery()
q.identities.extend([
disco_xso.Identity(
category="client",
type_="pc",
name="foobar"
),
disco_xso.Identity(
category="client",
type_="pc",
name="baz",
lang=structs.LanguageTag.fromstr("en-GB")
),
])
q.features.update(
[
"foo",
"bar",
"baz",
]
)
f = forms_xso.Data(type_=forms_xso.DataType.FORM)
f.fields.extend([
forms_xso.Field(
type_=forms_xso.FieldType.HIDDEN,
var="FORM_TYPE",
values=[
"fnord",
]),
forms_xso.Field(
type_=forms_xso.FieldType.TEXT_SINGLE,
var="uiae",
values=[
"nrtd",
"asdf",
]),
forms_xso.Field(type_=forms_xso.FieldType.FIXED),
])
q.exts.append(f)
self.assertDictEqual(
q.to_dict(),
{
"features": [
"bar",
"baz",
"foo",
],
"identities": [
{
"category": "client",
"type": "pc",
"name": "foobar",
},
{
"category": "client",
"type": "pc",
"name": "baz",
"lang": "en-gb",
},
],
"forms": [
{
"FORM_TYPE": [
"fnord",
],
"uiae": [
"nrtd",
"asdf",
]
}
]
}
)
def test_to_dict_emits_forms_with_identical_type(self):
q = disco_xso.InfoQuery()
q.identities.extend([
disco_xso.Identity(
category="client",
type_="pc",
name="foobar"
),
disco_xso.Identity(
category="client",
type_="pc",
name="baz",
lang=structs.LanguageTag.fromstr("en-GB")
),
])
q.features.update(
[
"foo",
"bar",
"baz",
]
)
f = forms_xso.Data(type_=forms_xso.DataType.FORM)
f.fields.extend([
forms_xso.Field(type_=forms_xso.FieldType.HIDDEN,
var="FORM_TYPE",
values=[
"fnord",
]),
forms_xso.Field(type_=forms_xso.FieldType.TEXT_SINGLE,
var="uiae",
values=[
"nrtd",
"asdf",
]),
forms_xso.Field(type_=forms_xso.FieldType.FIXED),
])
q.exts.append(f)
f = forms_xso.Data(type_=forms_xso.DataType.FORM)
f.fields.extend([
forms_xso.Field(type_=forms_xso.FieldType.HIDDEN,
var="FORM_TYPE",
values=[
"fnord",
]),
])
q.exts.append(f)
self.assertDictEqual(
q.to_dict(),
{
"features": [
"bar",
"baz",
"foo",
],
"identities": [
{
"category": "client",
"type": "pc",
"name": "foobar",
},
{
"category": "client",
"type": "pc",
"name": "baz",
"lang": "en-gb",
},
],
"forms": [
{
"FORM_TYPE": [
"fnord",
],
"uiae": [
"nrtd",
"asdf",
]
},
{
"FORM_TYPE": [
"fnord",
],
}
]
}
)
def test__set_captured_events(self):
data = object()
iq = disco_xso.InfoQuery()
iq._set_captured_events(data)
self.assertIs(iq.captured_events, data)
class TestItem(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(disco_xso.Item, xso.XSO))
def test_tag(self):
self.assertEqual(
(namespaces.xep0030_items, "item"),
disco_xso.Item.TAG
)
def test_jid_attr(self):
self.assertIsInstance(
disco_xso.Item.jid,
xso.Attr
)
self.assertEqual(
(None, "jid"),
disco_xso.Item.jid.tag
)
self.assertIsInstance(
disco_xso.Item.jid.type_,
xso.JID
)
self.assertIs(
disco_xso.Item.jid.default,
xso.NO_DEFAULT
)
def test_name_attr(self):
self.assertIsInstance(
disco_xso.Item.name,
xso.Attr
)
self.assertEqual(
(None, "name"),
disco_xso.Item.name.tag
)
self.assertIs(disco_xso.Item.name.default, None)
def test_node_attr(self):
self.assertIsInstance(
disco_xso.Item.node,
xso.Attr
)
self.assertEqual(
(None, "node"),
disco_xso.Item.node.tag
)
self.assertIs(disco_xso.Item.node.default, None)
def test_unknown_child_policy(self):
self.assertEqual(
xso.UnknownChildPolicy.DROP,
disco_xso.Item.UNKNOWN_CHILD_POLICY
)
def test_init(self):
with self.assertRaises(TypeError):
disco_xso.Item()
jid = structs.JID.fromstr("foo@bar.example/baz")
item = disco_xso.Item(jid)
self.assertIsNone(item.name)
self.assertIsNone(item.node)
item = disco_xso.Item(jid=jid, name="fnord", node="test")
self.assertEqual(jid, item.jid)
self.assertEqual("fnord", item.name)
self.assertEqual("test", item.node)
class TestItemsQuery(unittest.TestCase):
def test_is_xso(self):
self.assertTrue(issubclass(disco_xso.ItemsQuery, xso.XSO))
def test_tag(self):
self.assertEqual(
(namespaces.xep0030_items, "query"),
disco_xso.ItemsQuery.TAG
)
def test_node_attr(self):
self.assertIsInstance(
disco_xso.ItemsQuery.node,
xso.Attr
)
self.assertEqual(
(None, "node"),
disco_xso.ItemsQuery.node.tag
)
self.assertIs(disco_xso.ItemsQuery.node.default, None)
def test_items_attr(self):
self.assertIsInstance(
disco_xso.ItemsQuery.items,
xso.ChildList
)
self.assertSetEqual(
{disco_xso.Item},
set(disco_xso.ItemsQuery.items._classes)
)
def test_registered_at_IQ(self):
self.assertIn(
disco_xso.ItemsQuery.TAG,
stanza.IQ.CHILD_MAP
)
def test_init(self):
iq = disco_xso.ItemsQuery()
self.assertIsNone(iq.node)
self.assertFalse(iq.items)
iq = disco_xso.ItemsQuery(node="test", items=(1, 2))
self.assertEqual("test", iq.node)
self.assertIsInstance(iq.items, xso_model.XSOList)
self.assertSequenceEqual(
[1, 2],
iq.items
)
tests/entitycaps/ 0000775 0000000 0000000 00000000000 14160146213 0014401 5 ustar 00root root 0000000 0000000 tests/entitycaps/__init__.py 0000664 0000000 0000000 00000001554 14160146213 0016517 0 ustar 00root root 0000000 0000000 ########################################################################
# 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
# .
#
########################################################################
tests/entitycaps/test___init__.py 0000664 0000000 0000000 00000002763 14160146213 0017561 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test___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
# .
#
########################################################################
import unittest
import aioxmpp.entitycaps
import aioxmpp.entitycaps.service
import aioxmpp.entitycaps.xso
class TestExports(unittest.TestCase):
def test_exports(self):
self.assertIs(aioxmpp.entitycaps.EntityCapsService,
aioxmpp.entitycaps.service.EntityCapsService)
self.assertIs(aioxmpp.entitycaps.Cache,
aioxmpp.entitycaps.service.Cache)
self.assertIs(aioxmpp.entitycaps.Service,
aioxmpp.entitycaps.service.EntityCapsService)
self.assertIs(aioxmpp.EntityCapsService,
aioxmpp.entitycaps.service.EntityCapsService)
tests/entitycaps/test_caps115.py 0000664 0000000 0000000 00000066411 14160146213 0017177 0 ustar 00root root 0000000 0000000 ########################################################################
# File name: test_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
# .
#
########################################################################
import contextlib
import functools
import io
import pathlib
import unittest
import unittest.mock
import aioxmpp
import aioxmpp.disco as disco
import aioxmpp.entitycaps.caps115 as caps115
import aioxmpp.entitycaps.xso as caps_xso
import aioxmpp.forms.xso as forms_xso
import aioxmpp.structs as structs
_src = io.BytesIO(b"""\
<\
feature var="http://jabber.org/protocol/disco#items"/>urn:xmpp:dataforms:software\
infoTkabber1.0-svn-20140122 (Tcl/Tk 8.4.20)FreeBSD\
10.0-STABLE""")
TEST_DB_ENTRY = aioxmpp.xml.read_single_xso(_src, disco.xso.InfoQuery)
TEST_DB_ENTRY_VER = "+0mnUAF1ozCEc37cmdPPsYbsfhg="
TEST_DB_ENTRY_HASH = "sha-1"
TEST_DB_ENTRY_NODE_BARE = "http://tkabber.jabber.ru/"
class Testbuild_identities_string(unittest.TestCase):
def test_identities(self):
identities = [
disco.xso.Identity(category="fnord",
type_="bar"),
disco.xso.Identity(category="client",
type_="bot",
name="aioxmpp library"),
disco.xso.Identity(category="client",
type_="bot",
name="aioxmpp Bibliothek",
lang=structs.LanguageTag.fromstr("de-de")),
]
self.assertEqual(
b"client/bot//aioxmpp library<"
b"client/bot/de-de/aioxmpp Bibliothek<"
b"fnord/bar//<",
caps115.build_identities_string(identities)
)
def test_escaping(self):
identities = [
disco.xso.Identity(category="fnord",
type_="bar"),
disco.xso.Identity(category="client",
type_="bot",
name="aioxmpp library > 0.5"),
disco.xso.Identity(category="client",
type_="bot",
name="aioxmpp Bibliothek <& 0.5",
lang=structs.LanguageTag.fromstr("de-de")),
]
self.assertEqual(
b"client/bot//aioxmpp library > 0.5<"
b"client/bot/de-de/aioxmpp Bibliothek <& 0.5<"
b"fnord/bar//<",
caps115.build_identities_string(identities)
)
def test_reject_duplicate_identities(self):
identities = [
disco.xso.Identity(category="fnord",
type_="bar"),
disco.xso.Identity(category="client",
type_="bot",
name="aioxmpp library > 0.5"),
disco.xso.Identity(category="client",
type_="bot",
name="aioxmpp Bibliothek <& 0.5",
lang=structs.LanguageTag.fromstr("de-de")),
disco.xso.Identity(category="client",
type_="bot",
name="aioxmpp library > 0.5"),
]
with self.assertRaisesRegex(ValueError,
"duplicate identity"):
caps115.build_identities_string(identities)
class Testbuild_features_string(unittest.TestCase):
def test_features(self):
features = [
"http://jabber.org/protocol/disco#info",
"http://jabber.org/protocol/caps",
"http://jabber.org/protocol/disco#items",
]
self.assertEqual(
b"http://jabber.org/protocol/caps<"
b"http://jabber.org/protocol/disco#info<"
b"http://jabber.org/protocol/disco#items<",
caps115.build_features_string(features)
)
def test_escaping(self):
features = [
"http://jabber.org/protocol/c<>&aps",
]
self.assertEqual(
b"http://jabber.org/protocol/c<>&aps<",
caps115.build_features_string(features)
)
def test_reject_duplicate_features(self):
features = [
"http://jabber.org/protocol/disco#info",
"http://jabber.org/protocol/caps",
"http://jabber.org/protocol/disco#items",
"http://jabber.org/protocol/caps",
]
with self.assertRaisesRegex(ValueError,
"duplicate feature"):
caps115.build_features_string(features)
class Testbuild_forms_string(unittest.TestCase):
def test_xep_form(self):
forms = [forms_xso.Data(type_=forms_xso.DataType.FORM)]
forms[0].fields.extend([
forms_xso.Field(
var="FORM_TYPE",
values=[
"urn:xmpp:dataforms:softwareinfo",
]
),
forms_xso.Field(
var="os_version",
values=[
"10.5.1",
]
),
forms_xso.Field(
var="os",
values=[
"Mac",
]
),
forms_xso.Field(
var="ip_version",
values=[
"ipv4",
"ipv6",
]
),
forms_xso.Field(
var="software",
values=[
"Psi",
]
),
forms_xso.Field(
var="software_version",
values=[
"0.11",
]
),
])
self.assertEqual(
b"urn:xmpp:dataforms:softwareinfo<"
b"ip_version