pax_global_header 0000666 0000000 0000000 00000000064 13415717537 0014527 g ustar 00root root 0000000 0000000 52 comment=f151f920f439d97d4103fc11057ed6dc34fe98be
.coveragerc 0000664 0000000 0000000 00000000261 13415717537 0013213 0 ustar 00root root 0000000 0000000 [run]
source=
aioxmpp
omit=
aioxmpp/benchtest/*
aioxmpp/e2etest/*
aioxmpp/_ssl_transport.py
*/python?.?/*
*/python?.?-dev/*
*/dist-packages/*
*/site-packages/*
.gitignore 0000664 0000000 0000000 00000000107 13415717537 0013061 0 ustar 00root root 0000000 0000000 __pycache__
xmltest.py
dist
aioxmpp.egg-info
.local
.vagrant
.coverage
.travis-pinstore.json 0000664 0000000 0000000 00000000634 13415717537 0015220 0 ustar 00root root 0000000 0000000 {"localhost": ["MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6CZwixsiEk5Mzwi2A6mtdHA/UYTcf3drO6FRXPvr7neHJj4jGkXbj8uCMAlHTY70Is/b3x47YFU07QifQtn9VshMdqj2JAK1VFAEtSeGTDwjs8JBjauuiqw5g45iXZTg/TdtwwX62kajlizE4E502yBUsKf8uF/N0HJxuRelB8vqT1jgGZZegIHzhO4vtqqseTy1t5J8nu3gGAxctR3hd2EXszPW/08BknuDHXDkEuIN9eXCg2X9ANNSTDg+EA0Wu0XeCBuMj7rlMqI5Ld3KxLd5VYSeU+PMiyM30KswQsVx3AqYyCSQtGjETFYkozhPNfJznD9vuJYiv6BCCMUw3wIDAQAB"]}
.travis.yml 0000664 0000000 0000000 00000004066 13415717537 0013212 0 ustar 00root root 0000000 0000000 language: python
cache:
pip: true
python:
- "3.4"
- "3.5"
- "3.6"
env:
- TEST_MODE=e2e-prosody PROSODY_BRANCH=0.10
matrix:
include:
- python: "3.6"
env: TEST_MODE=e2e-ejabberd EJABBERD_VERSION=latest
services:
- docker
- python: "3.6"
env: TEST_MODE=e2e-ejabberd EJABBERD_VERSION=18.01
services:
- docker
- python: "3.6"
env: TEST_MODE=e2e-ejabberd EJABBERD_VERSION=18.03
services:
- docker
- python: "3.6"
env: TEST_MODE=e2e-prosody PROSODY_BRANCH=0.9
- python: "3.6"
env: TEST_MODE=e2e-prosody PROSODY_BRANCH=trunk
- python: "3.6"
env: TEST_MODE=e2e-metronome METRONOME_VERSION=master
addons:
apt:
packages:
- libevent-dev
- python: "3.6"
env: TEST_MODE=coverage
- sudo: required
dist: xenial
python: "3.7"
addons:
apt:
update: yes
env: TEST_MODE=e2e-prosody PROSODY_BRANCH=0.10 WITH_BUILD_DEP=yes
allow_failures:
- python: "3.6"
env: TEST_MODE=e2e-prosody PROSODY_BRANCH=trunk
- python: "3.6"
env: TEST_MODE=e2e-metronome METRONOME_VERSION=master
addons:
apt:
packages:
- libevent-dev
- python: "3.6"
env: TEST_MODE=e2e-ejabberd EJABBERD_VERSION=latest
services:
- docker
before_install:
- export PATH=$PWD/lua_install/bin:$PATH
- if [[ "x$TEST_MODE" = 'xe2e-prosody' ]]; then ./utils/install-prosody.sh; fi
- if [[ "x$TEST_MODE" = 'xe2e-metronome' ]]; then ./utils/install-metronome.sh; fi
- if [[ "x$TEST_MODE" = 'xe2e-ejabberd' ]]; then ./utils/prepare-ejabberd.sh; fi
install:
- pip install nose coveralls
- pip install .
script:
- export PATH=$PWD/lua_install/bin:$PATH
- if [[ "x$TEST_MODE" = 'xe2e-prosody' ]]; then ./utils/travis-e2etest-prosody.py; fi
- if [[ "x$TEST_MODE" = 'xe2e-metronome' ]]; then ./utils/travis-e2etest-metronome.py; fi
- if [[ "x$TEST_MODE" = 'xe2e-ejabberd' ]]; then ./utils/travis-e2etest-ejabberd.py; fi
- if [[ "x$TEST_MODE" = 'xcoverage' ]]; then nosetests --with-cover --cover-package aioxmpp tests; fi
after_success:
- if [[ "x$TEST_MODE" = 'xcoverage' ]]; then coveralls; fi
COPYING.LESSER 0000664 0000000 0000000 00000016743 13415717537 0013135 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 13415717537 0012776 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 13415717537 0012224 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 13415717537 0012624 0 ustar 00root root 0000000 0000000 include COPYING.gpl3
include COPYING.LESSER
include LICENSES
include docs/licenses/*.txt
Makefile 0000664 0000000 0000000 00000000373 13415717537 0012536 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 00000013027 13415717537 0012565 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
... 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 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.
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 Wielicki
`_, 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.
Change log
==========
The `change log`_ is included in the `official documentation`_.
.. _change log: https://docs.zombofant.net/aioxmpp/0.10/api/changelog.html
.. _official documentation: https://docs.zombofant.net/aioxmpp/0.10/
.. _quick start: https://docs.zombofant.net/aioxmpp/0.10/user-guide/quickstart.html
.. _API reference: https://docs.zombofant.net/aioxmpp/0.10/api/index.html
aioxmpp/ 0000775 0000000 0000000 00000000000 13415717537 0012550 5 ustar 00root root 0000000 0000000 aioxmpp/__init__.py 0000664 0000000 0000000 00000010373 13415717537 0014665 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
#: 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
from .structs import ( # NOQA
JID,
PresenceShow,
PresenceState,
MessageType,
PresenceType,
IQType,
ErrorType,
)
from .security_layer import make as make_security_layer # NOQA
from .node import Client, PresenceManagedClient # NOQA
# services
from .presence import PresenceClient, PresenceServer # NOQA
from .roster import RosterClient # NOQA
from .disco import DiscoServer, DiscoClient # NOQA
from .entitycaps import EntityCapsService # NOQA
from .muc import MUCClient # NOQA
from .pubsub import PubSubClient # NOQA
from .shim import SHIMService # NOQA
from .adhoc import AdHocClient, AdHocServer # NOQA
from .avatar import AvatarService # NOQA
from .blocking import BlockingClient # NOQA
from .carbons import CarbonsClient # NOQA
from .ping import PingService # NOQA
from .pep import PEPClient # NOQA
from .bookmarks import BookmarkClient # NOQA
from .version import VersionServer # NOQA
from .mdr import DeliveryReceiptsService # NOQA
from . import httpupload
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 13415717537 0014740 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, 10, 3, None)
__version__ = ".".join(map(str, version_info[:3])) + ("-"+version_info[3] if
version_info[3] else "")
version = __version__
aioxmpp/adhoc/ 0000775 0000000 0000000 00000000000 13415717537 0013626 5 ustar 00root root 0000000 0000000 aioxmpp/adhoc/__init__.py 0000664 0000000 0000000 00000003576 13415717537 0015752 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
AdHocClient,
ClientSession,
AdHocServer,
)
from .xso import ( # NOQA
CommandStatus,
ActionType,
)
from . import xso # NOQA
aioxmpp/adhoc/service.py 0000664 0000000 0000000 00000063522 13415717537 0015650 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]
@asyncio.coroutine
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 = yield from disco.query_items(
peer_jid,
node=namespaces.xep0050_commands,
)
return response.items
@asyncio.coroutine
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 = yield from disco.query_info(
peer_jid,
node=command_name,
)
return response
@asyncio.coroutine
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 = yield from disco.query_info(
peer_jid,
)
return namespaces.xep0050_commands in response.features
@asyncio.coroutine
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,
)
yield from 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)
@asyncio.coroutine
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 (yield from 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_stateful_command(self, node, name, handler, *,
# is_allowed=None,
# features={namespaces.xep0004_data}):
# """
# Register a handler for a stateful 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 spawn when a new session is
# started.
# :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`
# Whenever a new session is started, `handler` is invoked with a session
# object which allows the handler to communicate with the client. The
# details of the session are described at :class:`ServerSession`.
# If `is_allowed` is not :data:`None`, it is invoked whenever a command
# listing is generated and whenever a command session is about to start.
# 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`.
# .. warning::
# There is currently no rate-limiting mechanism implemented. It is
# trivial for an attacker to exhaust memory by starting a huge amount
# of sessions.
# """
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}
@asyncio.coroutine
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 = yield from self._stream.send_iq_and_wait_for_reply(
request,
)
return self._response.first_payload
@asyncio.coroutine
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 = \
yield from 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)):
yield from self.close()
raise SessionError(exc.text)
if isinstance(exc, aioxmpp.errors.XMPPCancelError):
yield from self.close()
raise
return self._response.first_payload
@asyncio.coroutine
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:
yield from 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
@asyncio.coroutine
def __aenter__(self):
if self._response is None:
yield from self.start()
return self
@asyncio.coroutine
def __aexit__(self, exc_type, exc_value, exc_traceback):
yield from self.close()
# class ServerSession:
# """
# Represent an Ad-Hoc Commands session on the server side.
# :param stream: The stanza stream to communicate over
# :type stream: :class:`~.stream.StanzaStream`
# :param sessionid: Session ID to use for communication
# :type sessionid: :class:`str` or :data:`None`
# :param timeout: Maximum time to wait for a follow-up from the peer
# :type timeout: :class:`datetime.timedelta`
# The session knows its session ID and the peer JID and keeps track of client
# timeouts as well as the most recent reply.
# If `sessionid` is :data:`None`, a random session ID with at least 64 bits
# of entropy is generated.
# """
# def __init__(self, stream,
# sessionid=None,
# timeout=timedelta(seconds=60)):
# super().__init__()
# self.stream = stream
# self.peer_jid = peer_jid
# if sessionid is None:
# sessionid = base64.urlsafe_b64encode(
# _rng.getrandbits(64).to_bytes(
# 64//8,
# "little"
# )
# ).rstrip(b"=").decode("ascii")
# self.sessionid = sessionid
# self.timeout = timeout
# self._future = None
# @asyncio.coroutine
# def handle(self, stanza):
# pass
# @asyncio.coroutine
# def reply(self, payload, status,
# *,
# actions={adhoc_xso.ActionType.NEXT,
# adhoc_xso.ActionType.COMPLETE},
# default_action=adhoc_xso.ActionType.NEXT):
# """
# Send a reply to the peer.
# :param payload: Payload to send in the reply.
# :type payload: :class:`~.XSO` or sequence of :class:`~.XSO`
# :param status: Status of the command execution.
# :type status: :class:`~.adhoc.CommandStatus`
# :param actions: Set of actions allowed now.
# :type actions: set of :class:`~.adhoc.xso.ActionType`
# :param default_action: The action to assume if the client simply
# continues with
# :attr:`~.adhoc.xso.ActionType.EXECUTE`.
# :type default_action: :class:`~.adhoc.xso.ActionType` member which is
# not :attr:`~.adhoc.xso.ActionType.EXECUTE`
# :raise ClientCancelledError: if the client cancels the execution
# :raise RuntimeError: if the client has not yet sent a request
# :raise TimeoutError: if the client does not send a follow-up message in
# time
# :return: The chosen action and the payload given by the peer.
# :rtype: Pair of :class:`~.adhoc.xso.ActionType` and sequence of
# :class:`~.XSO` objects.
# Send a reply to the peer. The `payload` must be a single
# :class:`~.XSO` or a sequence of :class:`~.XSO` objects. A single
# :class:`~.XSO` is wrapped in a sequence. The sequence is used as
# payload for the Ad-Hoc Command response.
# `status` informs the client about the current status of execution. For
# all but the last reply, this should be
# :attr:`~.adhoc.CommandStatus.EXECUTING`, but it should be set to
# :attr:`~.adhoc.CommandStatus.COMPLETED` on the last reply.
# Unfortunately, we do not have a sensible way to infer this, which is
# why there is no default for this argument.
# `actions` is the set of actions allowed for the client. The default is
# to allow the :attr:`~.adhoc.xso.ActionType.NEXT` and
# :attr:`~.adhoc.xso.ActionType.COMPLETE` actions. Depending on your
# application, you may need a different set of actions. You do not need
# to specify the :attr:`~.adhoc.xso.ActionType.EXECUTE` or
# :attr:`~.adhoc.xso.ActionType.CANCEL` action types, which are
# implicitly allowed.
# `default_action` is the action which is assumed when the client simply
# specifies the :attr:`~.adhoc.xso.ActionType.EXECUTE` action. It
# defaults to :attr:`~.adhoc.xso.ActionType.NEXT` and *must* be included
# in the `actions` set.
# The response from the client is generally returned a as a tuple
# consisting of the action chosen by the client and the payload sent by
# the client.
# If the client chooses the :attr:`~.adhoc.xso.ActionType.CANCEL` action,
# a :class:`RuntimeError` exception is raised and a confirmation of
# cancellation is sent to the client automatically and the session is
# closed.
# If the client does not answer within the timeout, :class:`TimeoutError`
# is raised and the session is closed.
# """
aioxmpp/adhoc/xso.py 0000664 0000000 0000000 00000013041 13415717537 0015010 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 13415717537 0014026 5 ustar 00root root 0000000 0000000 aioxmpp/avatar/__init__.py 0000664 0000000 0000000 00000005346 13415717537 0016147 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
normalize_id)
aioxmpp/avatar/service.py 0000664 0000000 0000000 00000103543 13415717537 0016046 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 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 attribues 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)
@asyncio.coroutine
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
@asyncio.coroutine
def get_image_bytes(self):
image_data = yield from 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):
@asyncio.coroutine
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 explicitely 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
@asyncio.coroutine
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 = yield from 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
ressources 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 alread 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
)
@asyncio.coroutine
def _calculate_vcard_id(self):
self.logger.debug("updating vcard hash")
vcard = yield from 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)
@asyncio.coroutine
def _get_avatar_metadata_vcard(self, jid):
logger.debug("trying vCard avatar as fallback for %s", jid)
vcard = yield from 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,
)]
@asyncio.coroutine
def _get_avatar_metadata_pep(self, jid):
try:
metadata_raw = yield from 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)
@asyncio.coroutine
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 chache, 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 = yield from 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 = yield from 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]
@asyncio.coroutine
def subscribe(self, jid):
"""
Explicitly subscribe to metadata change notifications for `jid`.
"""
yield from 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()
@asyncio.coroutine
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
with (yield from self._publish_lock):
if (yield from self._pep.available()):
yield from self._pep.publish(
namespaces.xep0084_data,
avatar_xso.Data(avatar_set.image_bytes),
id_=id_
)
yield from self._pep.publish(
namespaces.xep0084_metadata,
avatar_set.metadata,
id_=id_
)
done = True
if self._synchronize_vcard:
my_vcard = yield from self._vcard.get_vcard()
my_vcard.set_photo_data("image/png",
avatar_set.image_bytes)
self._vcard_id = avatar_set.png_id
yield from 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"
)
@asyncio.coroutine
def _disable_vcard_avatar(self):
my_vcard = yield from self._vcard.get_vcard()
my_vcard.clear_photo_data()
self._vcard_id = ""
yield from self._vcard.set_vcard(my_vcard)
self._presence_server.resend_presence()
@asyncio.coroutine
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.
"""
with (yield from self._publish_lock):
todo = []
if self._synchronize_vcard:
todo.append(self._disable_vcard_avatar())
if (yield from self._pep.available()):
todo.append(self._pep.publish(
namespaces.xep0084_metadata,
avatar_xso.Metadata()
))
yield from gather_reraise_multi(*todo, message="disable_avatar")
@asyncio.coroutine
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.
"""
@asyncio.coroutine
def _wipe_pep_avatar():
yield from self._pep.publish(
namespaces.xep0084_metadata,
avatar_xso.Metadata()
)
yield from self._pep.publish(
namespaces.xep0084_data,
avatar_xso.Data(b'')
)
with (yield from self._publish_lock):
todo = []
if self._synchronize_vcard:
todo.append(self._disable_vcard_avatar())
if (yield from self._pep.available()):
todo.append(_wipe_pep_avatar())
yield from gather_reraise_multi(*todo, message="wipe_avatar")
aioxmpp/avatar/xso.py 0000664 0000000 0000000 00000013075 13415717537 0015217 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 13415717537 0014527 5 ustar 00root root 0000000 0000000 aioxmpp/benchtest/__init__.py 0000664 0000000 0000000 00000020444 13415717537 0016644 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 13415717537 0016622 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 13415717537 0014340 5 ustar 00root root 0000000 0000000 aioxmpp/blocking/__init__.py 0000664 0000000 0000000 00000002436 13415717537 0016456 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
aioxmpp/blocking/service.py 0000664 0000000 0000000 00000017267 13415717537 0016367 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()
@asyncio.coroutine
def _check_for_blocking(self):
server_info = yield from 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")
@asyncio.coroutine
def _get_initial_blocklist(self):
try:
yield from 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:
with (yield from self._lock):
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.GET,
payload=blocking_xso.BlockList(),
)
result = yield from 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
@asyncio.coroutine
def block_jids(self, jids_to_block):
"""
Add the JIDs in the sequence `jids_to_block` to the client's
blocklist.
"""
yield from 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,
)
yield from self.client.send(iq)
@asyncio.coroutine
def unblock_jids(self, jids_to_unblock):
"""
Remove the JIDs in the sequence `jids_to_block` from the
client's blocklist.
"""
yield from 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,
)
yield from self.client.send(iq)
@asyncio.coroutine
def unblock_all(self):
"""
Unblock all JIDs currently blocked.
"""
yield from self._check_for_blocking()
cmd = blocking_xso.UnblockCommand()
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=cmd,
)
yield from self.client.send(iq)
@service.iq_handler(aioxmpp.IQType.SET, blocking_xso.BlockCommand)
@asyncio.coroutine
def handle_block_push(self, block_command):
diff = ()
with (yield from 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)
@asyncio.coroutine
def handle_unblock_push(self, unblock_command):
diff = ()
with (yield from 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 13415717537 0015531 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 13415717537 0014540 5 ustar 00root root 0000000 0000000 aioxmpp/bookmarks/__init__.py 0000664 0000000 0000000 00000004201 13415717537 0016646 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
as_bookmark_class)
from .service import BookmarkClient # NOQA
aioxmpp/bookmarks/service.py 0000664 0000000 0000000 00000043561 13415717537 0016563 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)
@asyncio.coroutine
def _stream_established(self):
yield from self.sync()
@asyncio.coroutine
def _get_bookmarks(self):
"""
Get the stored bookmarks from the server.
:returns: a list of bookmarks
"""
res = yield from self._private_xml.get_private_xml(
bookmark_xso.Storage()
)
return res.registered_payload.bookmarks
@asyncio.coroutine
def _set_bookmarks(self, bookmarks):
"""
Set the bookmarks stored on the server.
"""
storage = bookmark_xso.Storage()
storage.bookmarks[:] = bookmarks
yield from 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
# defininition 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
@asyncio.coroutine
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
"""
with (yield from self._lock):
bookmarks = yield from self._get_bookmarks()
self._diff_emit_update(bookmarks)
return bookmarks
@asyncio.coroutine
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).
"""
with (yield from self._lock):
yield from self._set_bookmarks(bookmarks)
self._diff_emit_update(bookmarks)
@asyncio.coroutine
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.
"""
yield from self.get_bookmarks()
@asyncio.coroutine
def add_bookmark(self, new_bookmark, *, max_retries=3):
"""
Add a bookmark and check whether it was successfully added to the
bookmark list. Already existant 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`.
"""
with (yield from self._lock):
bookmarks = yield from self._get_bookmarks()
try:
modified_bookmarks = list(bookmarks)
if new_bookmark not in bookmarks:
modified_bookmarks.append(new_bookmark)
yield from self._set_bookmarks(modified_bookmarks)
retries = 0
bookmarks = yield from self._get_bookmarks()
while retries < max_retries:
if new_bookmark in bookmarks:
break
modified_bookmarks = list(bookmarks)
modified_bookmarks.append(new_bookmark)
yield from self._set_bookmarks(modified_bookmarks)
bookmarks = yield from self._get_bookmarks()
retries += 1
if new_bookmark not in bookmarks:
raise RuntimeError("Could not add bookmark")
finally:
self._diff_emit_update(bookmarks)
@asyncio.coroutine
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 occurences 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`.
"""
with (yield from self._lock):
bookmarks = yield from self._get_bookmarks()
occurences = bookmarks.count(bookmark_to_remove)
try:
if not occurences:
return
modified_bookmarks = list(bookmarks)
modified_bookmarks.remove(bookmark_to_remove)
yield from self._set_bookmarks(modified_bookmarks)
retries = 0
bookmarks = yield from self._get_bookmarks()
new_occurences = bookmarks.count(bookmark_to_remove)
while retries < max_retries:
if new_occurences < occurences:
break
modified_bookmarks = list(bookmarks)
modified_bookmarks.remove(bookmark_to_remove)
yield from self._set_bookmarks(modified_bookmarks)
bookmarks = yield from self._get_bookmarks()
new_occurences = bookmarks.count(bookmark_to_remove)
retries += 1
if new_occurences >= occurences:
raise RuntimeError("Could not remove bookmark")
finally:
self._diff_emit_update(bookmarks)
@asyncio.coroutine
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
with (yield from self._lock):
bookmarks = yield from self._get_bookmarks()
try:
yield from self._set_bookmarks(
replace_bookmark(bookmarks, old, new)
)
retries = 0
bookmarks = yield from self._get_bookmarks()
while retries < max_retries:
if new in bookmarks:
break
yield from self._set_bookmarks(
replace_bookmark(bookmarks, old, new)
)
bookmarks = yield from 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 13415717537 0015733 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 13415717537 0014172 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 00000065421 13415717537 0015051 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.futures.InvalidStateError:
pass
return True
def error(self, exc):
try:
self.fut.set_exception(exc)
except asyncio.futures.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
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 kwargs:
raise TypeError("keyword arguments not supported")
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)
@asyncio.coroutine
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 = yield from 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 13415717537 0014177 5 ustar 00root root 0000000 0000000 aioxmpp/carbons/__init__.py 0000664 0000000 0000000 00000004036 13415717537 0016313 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
aioxmpp/carbons/service.py 0000664 0000000 0000000 00000006522 13415717537 0016216 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,
]
@asyncio.coroutine
def _check_for_feature(self):
disco_client = self.dependencies[aioxmpp.DiscoClient]
info = yield from 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
)
)
@asyncio.coroutine
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`
"""
yield from self._check_for_feature()
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=carbons_xso.Enable()
)
yield from self.client.send(iq)
@asyncio.coroutine
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`
"""
yield from self._check_for_feature()
iq = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
payload=carbons_xso.Disable()
)
yield from self.client.send(iq)
aioxmpp/carbons/xso.py 0000664 0000000 0000000 00000005671 13415717537 0015373 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 13415717537 0014713 5 ustar 00root root 0000000 0000000 aioxmpp/chatstates/__init__.py 0000664 0000000 0000000 00000003447 13415717537 0017034 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
from .utils import (ChatStateManager, DoNotEmit, AlwaysEmit, # NOQA
DiscoverSupport)
aioxmpp/chatstates/utils.py 0000664 0000000 0000000 00000010005 13415717537 0016421 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 13415717537 0016103 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 00000027472 13415717537 0015130 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
from aioxmpp.utils import namespaces
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
@asyncio.coroutine
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
@asyncio.coroutine
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, _ = yield from ssl_transport.create_starttls_connection(
loop,
lambda: stream,
host=host,
port=port,
peer_hostname=host,
server_hostname=domain,
use_starttls=True,
)
except: # NOQA
stream.abort()
raise
stream.deadtime_hard_limit = timedelta(seconds=negotiation_timeout)
features = yield from features_future
try:
features[nonza.StartTLSFeature]
except KeyError:
if not metadata.tls_required:
return transport, stream, (yield from features_future)
logger.debug(
"attempting STARTTLS despite not announced since it is"
" required")
try:
response = yield from protocol.send_and_wait_for(
stream,
[
nonza.StartTLS(),
],
[
nonza.StartTLSFailure,
nonza.StartTLSProceed,
]
)
except errors.StreamError as exc:
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, (yield from features_future)
verifier = metadata.certificate_verifier_factory()
yield from verifier.pre_handshake(
domain,
host,
port,
metadata,
)
ssl_context = metadata.ssl_context_factory()
verifier.setup_context(ssl_context, transport)
yield from stream.starttls(
ssl_context=ssl_context,
post_handshake_callback=verifier.post_handshake,
)
features_future = yield from protocol.reset_stream_and_get_features(
stream,
timeout=negotiation_timeout,
)
return transport, stream, features_future
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
@asyncio.coroutine
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()
yield from verifier.pre_handshake(
domain,
host,
port,
metadata,
)
context_factory = self._context_factory_factory(logger, metadata,
verifier)
try:
transport, _ = yield from ssl_transport.create_starttls_connection(
loop,
lambda: stream,
host=host,
port=port,
peer_hostname=host,
server_hostname=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, (yield from features_future)
aioxmpp/custom_queue.py 0000664 0000000 0000000 00000004311 13415717537 0015637 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(loop=self._loop)
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
@asyncio.coroutine
def get(self):
while not self._data:
yield from self._non_empty.wait()
return self.get_nowait()
def clear(self):
self._data.clear()
self._non_empty.clear()
aioxmpp/disco/ 0000775 0000000 0000000 00000000000 13415717537 0013651 5 ustar 00root root 0000000 0000000 aioxmpp/disco/__init__.py 0000664 0000000 0000000 00000006372 13415717537 0015772 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
from .service import (DiscoClient, DiscoServer, Node, StaticNode, # NOQA
mount_as_node, register_feature, RegisteredFeature)
aioxmpp/disco/service.py 0000664 0000000 0000000 00000101713 13415717537 0015666 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)
@asyncio.coroutine
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)
@asyncio.coroutine
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()
@asyncio.coroutine
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 = yield from self.client.send(
request_iq
)
return response
@asyncio.coroutine
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 simultanously 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 (yield from 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 = yield from asyncio.wait_for(
request,
timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError()
else:
result = yield from 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
@asyncio.coroutine
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 (yield from 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 = yield from asyncio.wait_for(
request,
timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError()
else:
result = yield from 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 unregisterd.
"""
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 13415717537 0015045 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 00000036505 13415717537 0015261 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`.
"""
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 13415717537 0014123 5 ustar 00root root 0000000 0000000 aioxmpp/e2etest/__init__.py 0000664 0000000 0000000 00000032134 13415717537 0016237 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:: 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
.. currentmodule:: aioxmpp.e2etest
.. autoclass:: Quirk
.. currentmodule:: aioxmpp.e2etest.provision
Helper functions
----------------
.. autofunction:: discover_server_features
.. autofunction:: configure_tls_config
.. autofunction:: configure_quirks
"""
import asyncio
import configparser
import functools
import importlib
import logging
import os
import unittest
from nose.plugins import Plugin
from .utils import blocking
from .provision import Quirk # NOQA
provisioner = None
config = None
timeout = 1
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_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)
@asyncio.coroutine
def wrapper(*args, **kwargs):
yield from 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)
@asyncio.coroutine
def wrapper(*args, **kwargs):
global timeout
yield from asyncio.wait_for(f(*args, **kwargs), timeout)
return wrapper
@blocking
@asyncio.coroutine
def setup_package():
global provisioner, config, timeout
if config is None:
# AioxmppPlugin is not used -> skip all e2e tests
for subclass in TestCase.__subclasses__():
# XXX: this depends on unittest implementation details :)
subclass.__unittest_skip__ = True
subclass.__unittest_skip_why__ = \
"this is not the aioxmpp test runner"
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)
yield from 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 E2ETestPlugin(Plugin):
name = "aioxmpp-e2e"
def options(self, options, env=os.environ):
options.add_option(
"--e2etest-config",
dest="aioxmpp_e2e_config",
metavar="FILE",
default=".local/e2etest.ini",
help="Configuration file for end-to-end tests "
"(default: .local/e2etest.ini)"
)
options.add_option(
"--e2etest-record",
dest="aioxmpp_e2e_record",
metavar="FILE",
default=None,
help="A file to write a transcript to"
)
def configure(self, options, conf):
self.enabled = True
global config
config = configparser.ConfigParser()
with open(options.aioxmpp_e2e_config, "r") as f:
config.read_file(f)
if options.aioxmpp_e2e_record:
handler = logging.FileHandler(options.aioxmpp_e2e_record, "w")
formatter = logging.Formatter(
"%(name)s: %(levelname)s: %(message)s",
style="%"
)
handler.setFormatter(formatter)
logger = logging.getLogger("aioxmpp.e2etest.provision")
logger.addHandler(handler)
@blocking
@asyncio.coroutine
def beforeTest(self, test):
global provisioner
if provisioner is not None:
yield from provisioner.setup()
@blocking
@asyncio.coroutine
def afterTest(self, test):
global provisioner
if provisioner is not None:
yield from provisioner.teardown()
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
"""
@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
aioxmpp/e2etest/__main__.py 0000664 0000000 0000000 00000001713 13415717537 0016217 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.e2etest import E2ETestPlugin
nose.main(addplugins=[E2ETestPlugin()])
aioxmpp/e2etest/provision.py 0000664 0000000 0000000 00000046562 13415717537 0016542 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 enum
import fnmatch
import json
import logging
import aioxmpp
import aioxmpp.disco
import aioxmpp.security_layer
import aioxmpp.connector
_logger = logging.getLogger(__name__)
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.
"""
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"
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, [])
)
@asyncio.coroutine
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* overriden -- so `peer` takes
precedence).
"""
server_info = yield from 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 = yield from disco.query_items(peer)
features_list = yield from 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
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:: 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._account_info = None
self._logger = logger
self.__counter = 0
@abc.abstractmethod
@asyncio.coroutine
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.
"""
@asyncio.coroutine
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 = yield from self._make_client(logger)
for service in services:
client.summon(service)
if prepare is not None:
yield from prepare(client)
cm = client.connected(presence=presence)
yield from 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_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
"""
@asyncio.coroutine
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.
"""
@asyncio.coroutine
def finalise(self):
"""
Called once on test framework shutdown (timeout of 10 seconds applies).
"""
@asyncio.coroutine
def setup(self):
"""
Called before each test run.
"""
@asyncio.coroutine
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))
yield from asyncio.gather(
*futures,
return_exceptions=True
)
class AnonymousProvisioner(Provisioner):
"""
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.
.. note::
Make sure to disable PEP (:xep:`163`) support on the server, to avoid
the ``…pubsub#publish`` feature to be bound to the server instead of the
pubsub component.
This is unfortunate because it prohibits testing PEP properly. This may
be fixed in a future release when anything PEP-specific is implemented.
"""
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.__blockmap = configure_blockmap(section)
self._quirks = configure_quirks(section)
@asyncio.coroutine
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,
)
@asyncio.coroutine
def initialise(self):
self._logger.debug("initialising anonymous provisioner")
client = yield from self.get_connected_client()
disco = client.summon(aioxmpp.DiscoClient)
self._featuremap.update(
(yield from discover_server_features(
disco,
self.__domain,
blockmap=self.__blockmap,
))
)
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 = yield from disco.query_info(None)
# clean up state
del client
yield from self.teardown()
aioxmpp/e2etest/utils.py 0000664 0000000 0000000 00000002662 13415717537 0015643 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 13415717537 0014733 5 ustar 00root root 0000000 0000000 aioxmpp/entitycaps/__init__.py 0000664 0000000 0000000 00000003464 13415717537 0017053 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
"""
from .service import EntityCapsService, Cache # NOQA
from . import xso # NOQA
Service = EntityCapsService
aioxmpp/entitycaps/caps115.py 0000664 0000000 0000000 00000011310 13415717537 0016456 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 13415717537 0016474 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 00000007477 13415717537 0016614 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 maches 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 00000040121 13415717537 0016743 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)
@asyncio.coroutine
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 = yield from 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 perfoms 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 algortihms 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
)
@asyncio.coroutine
def _shutdown(self):
for group in self.__current_keys.values():
for key in group:
self.disco_server.unmount_node(key.node)
@asyncio.coroutine
def query_and_cache(self, jid, key, fut):
data = yield from 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
@asyncio.coroutine
def lookup_info(self, jid, keys):
for key in keys:
try:
info = yield from 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 = yield from 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 13415717537 0016115 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 00000050102 13415717537 0014434 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 mechansims.
# 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
consturctor. 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
occurence 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 occurence
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 13415717537 0013676 5 ustar 00root root 0000000 0000000 aioxmpp/forms/__init__.py 0000664 0000000 0000000 00000012660 13415717537 0016014 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
"""
from . import xso
from .xso import ( # NOQA
Data,
DataType,
Field,
FieldType,
Reported,
Item,
)
from .fields import ( # NOQA
Boolean,
ListSingle,
ListMulti,
JIDSingle,
JIDMulti,
TextSingle,
TextMulti,
TextPrivate,
)
from .form import ( # NOQA
Form,
)
aioxmpp/forms/fields.py 0000664 0000000 0000000 00000105521 13415717537 0015522 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 overriden 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 mechansim 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 overriden 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 13415717537 0015226 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 00000045752 13415717537 0015076 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`s
: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()]
values_set = set(values_list)
if len(values_list) != len(values_set):
raise ValueError("duplicate option value")
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 00000016537 13415717537 0014411 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`).
This allows other extensions to easily embed hash digests in their protocols
(:class:`HashesParent`).
.. 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``.
XSOs
====
.. autoclass:: Hash
.. autoclass:: HashesParent()
"""
import hashlib
import aioxmpp.xso as xso
from aioxmpp.utils import namespaces
namespaces.xep0300_hashes2 = "urn:xmpp:hashes:2"
_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)
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 algortihm 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 as exc:
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 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(),
)
default_hash_algorithms = {
algo
for algo in ["sha-256", "sha3-256", "blake2b-256"]
if is_algo_supported(algo)
}
aioxmpp/httpupload/ 0000775 0000000 0000000 00000000000 13415717537 0014734 5 ustar 00root root 0000000 0000000 aioxmpp/httpupload/__init__.py 0000664 0000000 0000000 00000006156 13415717537 0017055 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 =
slot = await client.send(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
@asyncio.coroutine
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 (yield from client.send(IQ(
type_=IQType.GET,
to=service,
payload=payload
)))
aioxmpp/httpupload/xso.py 0000664 0000000 0000000 00000011717 13415717537 0016126 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
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 00000030275 13415717537 0013710 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 faciliate 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 specificaton 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'
"""
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/ibr/ 0000775 0000000 0000000 00000000000 13415717537 0013324 5 ustar 00root root 0000000 0000000 aioxmpp/ibr/__init__.py 0000664 0000000 0000000 00000001765 13415717537 0015446 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 .service import RegistrationService, get_registration_fields, register
from .service import get_used_fields
from .xso import Query
aioxmpp/ibr/service.py 0000664 0000000 0000000 00000013214 13415717537 0015337 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__)
@asyncio.coroutine
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 = yield from aioxmpp.protocol.send_and_wait_for(xmlstream,
[iq],
[aioxmpp.IQ],
timeout=timeout)
return reply.payload
@asyncio.coroutine
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:`xso.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()
yield from 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:`xso.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 XMPP In-Band Registration(:xep:`0077`).
This 'service' implements the possibility for an entity to register with a
XMPP server, cancel an existing registration, or change a password.
.. automethod:: get_client_info
.. automethod:: change_pass
.. automethod:: cancel_registration
.. automethod:: get_registration_fields
.. automethod:: register
"""
@asyncio.coroutine
def get_client_info(self):
"""
A query is sent to the server to obtain the client's data stored at the
server.
:return: :class:`xso.Query`
"""
iq = aioxmpp.IQ(
to=self.client.local_jid.bare().replace(localpart=None),
type_=aioxmpp.IQType.GET,
payload=xso.Query()
)
reply = (yield from self.client.send(iq))
return reply
@asyncio.coroutine
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)
)
yield from self.client.send(iq)
@asyncio.coroutine
def cancel_registration(self):
"""
Cancels the currents client's account with the server.
Even if the cancelation is succesful, 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
yield from self.client.send(iq)
aioxmpp/ibr/xso.py 0000664 0000000 0000000 00000010557 13415717537 0014517 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"
"""
XSO Definitions
===============
.. autoclass:: Query
"""
@aioxmpp.IQ.as_payload_class
class Query(xso.XSO):
"""
:xep:`077` In-Band Registraion query :class:`~aioxmpp.xso.XSO`.
.. 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 13415717537 0013155 5 ustar 00root root 0000000 0000000 aioxmpp/im/__init__.py 0000664 0000000 0000000 00000006440 13415717537 0015272 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
ConversationState,
ConversationFeature,
InviteMode,
)
from .service import ( # NOQA
ConversationService,
)
aioxmpp/im/body.py 0000664 0000000 0000000 00000002543 13415717537 0014470 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 00000103720 13415717537 0016244 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
alwyas 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 occured 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 emmited 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-existant 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.
"""
@asyncio.coroutine
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")
@asyncio.coroutine
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")
@asyncio.coroutine
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")
@asyncio.coroutine
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")
@asyncio.coroutine
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")
@asyncio.coroutine
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 00000011746 13415717537 0015666 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")
@asyncio.coroutine
def enable_carbons(self, *args):
carbons = self.dependencies[aioxmpp.carbons.CarbonsClient]
try:
yield from 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 13415717537 0014324 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 00000015670 13415717537 0014241 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)
@asyncio.coroutine
def send_message_tracked(self, msg):
raise self._not_implemented_error("message tracking")
@asyncio.coroutine
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 13415717537 0015201 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 13415717537 0013332 5 ustar 00root root 0000000 0000000 aioxmpp/mdr/__init__.py 0000664 0000000 0000000 00000003320 13415717537 0015441 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 accordinly 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
DeliveryReceiptsService,
compose_receipt,
)
aioxmpp/mdr/service.py 0000664 0000000 0000000 00000011305 13415717537 0015344 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` reciept 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 13415717537 0014521 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 13415717537 0013503 5 ustar 00root root 0000000 0000000 aioxmpp/misc/__init__.py 0000664 0000000 0000000 00000004421 13415717537 0015615 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()
Chat Markers (:xep:`333`)
=========================
.. autoclass:: ReceivedMarker
.. autoclass:: DisplayedMarker
.. autoclass:: AcknowledgedMarker
.. attribute:: aioxmpp.Message.xep0333_marker
Pre-Authenticated Roster Subcription (:xep:`379`)
=================================================
.. autoclass:: Preauth
.. attribute:: aioxmpp.Presence.xep0379_preauth
The pre-auth element associate with a subscription request.
"""
from .delay import Delay # NOQA
from .forwarding import Forwarded # NOQA
from .oob import OOBExtension # NOQA
from .markers import ReceivedMarker, DisplayedMarker, AcknowledgedMarker # NOQA
from .pars import Preauth # NOQA
aioxmpp/misc/delay.py 0000664 0000000 0000000 00000003560 13415717537 0015157 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 13415717537 0016221 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/markers.py 0000664 0000000 0000000 00000003043 13415717537 0015521 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 13415717537 0014637 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/pars.py 0000664 0000000 0000000 00000002567 13415717537 0015034 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/muc/ 0000775 0000000 0000000 00000000000 13415717537 0013334 5 ustar 00root root 0000000 0000000 aioxmpp/muc/__init__.py 0000664 0000000 0000000 00000007013 13415717537 0015446 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
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
MUCClient,
Occupant,
Room,
LeaveMode,
RoomState,
ServiceMember,
)
from . import xso # NOQA
from .xso import ( # NOQA
ConfigurationForm,
InfoForm,
VoiceRequestForm,
StatusCode,
)
Service = MUCClient # NOQA
aioxmpp/muc/service.py 0000664 0000000 0000000 00000233534 13415717537 0015360 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, timedelta
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
from aioxmpp.utils import namespaces
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 occured 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 chagne 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.
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.
"""
# this occupant state events
on_muc_suspend = aioxmpp.callbacks.Signal()
on_muc_resume = aioxmpp.callbacks.Signal()
on_muc_enter = 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
@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,
}
def _enter_active_state(self):
self._state = RoomState.ACTIVE
self._history_replay_occupants.clear()
def _suspend(self):
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.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 _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)
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,
)
)
elif (existing.presence_state != info.presence_state or
existing.presence_status != info.presence_status):
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)
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.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)
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
@asyncio.coroutine
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),
)
yield from self._service.client.send(
stanza
)
@asyncio.coroutine
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.
"""
yield from self.muc_set_role(
member.nick,
"none",
reason=reason
)
@asyncio.coroutine
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)
]
)
yield from self.service.client.send(iq)
@asyncio.coroutine
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")
yield from self.muc_set_affiliation(
member.direct_jid,
"outcast",
reason=reason
)
@asyncio.coroutine
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 (yield from self.service.set_affiliation(
self._mucjid,
jid, affiliation,
reason=reason))
@asyncio.coroutine
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)
yield from self.service.client.send(msg)
@asyncio.coroutine
def leave(self):
"""
Leave the MUC.
"""
fut = self.on_exit.future()
def cb(**kwargs):
fut.set_result(None)
return True # disconnect
self.on_exit.connect(cb)
presence = aioxmpp.stanza.Presence(
type_=aioxmpp.structs.PresenceType.UNAVAILABLE,
to=self._mucjid
)
yield from self.service.client.send(presence)
yield from fut
@asyncio.coroutine
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)
yield from self.service.client.send(msg)
@asyncio.coroutine
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_affiliation
.. automethod:: set_room_config
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.
"""
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 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 get_muc(self, mucjid):
try:
return self._joined_mucs[mucjid]
except KeyError:
return self._pending_mucs[mucjid][0]
@asyncio.coroutine
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
@asyncio.coroutine
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)
]
)
yield from self.client.send(iq)
@asyncio.coroutine
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 (yield from self.client.send(iq)).form
@asyncio.coroutine
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),
)
yield from self.client.send(iq)
aioxmpp/muc/xso.py 0000664 0000000 0000000 00000045332 13415717537 0014526 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 interchangably.
.. 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
occured.
.. 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 00000034754 13415717537 0014630 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
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
_state.resolver = dns.resolver.Resolver()
_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
@asyncio.coroutine
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 excedeed 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 constrast 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 = yield from 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:
yield from 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
@asyncio.coroutine
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 = yield from 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
@asyncio.coroutine
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 = yield from 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
@asyncio.coroutine
def find_xmpp_host_addr(loop, domain, attempts=3):
domain = domain.encode("IDNA")
items = lookup_srv(
service="xmpp-client",
domain=domain,
nattempts=attempts
)
if items is not None:
return items
return [(0, 0, (domain, 5222))]
@asyncio.coroutine
def find_xmpp_host_tlsa(loop, domain, attempts=3, require_ad=True):
domain = domain.encode("IDNA")
items = yield from 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 00000162737 13415717537 0014067 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,
)
from .utils import namespaces
logger = logging.getLogger(__name__)
def lookup_addresses(loop, jid):
addresses = yield from network.find_xmpp_host_addr(
loop,
jid.domain)
return network.group_and_order_srv_records(addresses)
@asyncio.coroutine
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 = yield from 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 = yield from 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
@asyncio.coroutine
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 = yield from 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 = yield from 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
@asyncio.coroutine
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 mechansims 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 = yield from _try_options(
options,
exceptions,
jid, metadata, negotiation_timeout, loop, logger,
)
if result is not None:
return result
options = list((yield from discover_connectors(
jid.domain,
loop=loop,
logger=logger,
)))
result = yield from _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.
:param local_jid: Jabber ID to connect as
:type local_jid: :class:`~aioxmpp.JID`
:param security_layer: Configuration for authentication and TLS
:type security_layer: :class:`~aioxmpp.SecurityLayer`
:param negotiation_timeout: Timeout for the individual stream negotiation
steps (bounds initial connection time)
:type negotiation_timeout: :class:`datetime.timedelta`
:param override_peer: Connection options which take precedence over the
standardised connection options
:type override_peer: sequence of connection option triples
:param max_inital_attempts: Maximum number of initial connection attempts
:type max_initial_attempts: :class:`int`
:param loop: Override the :mod:`asyncio` event loop to use
:type loop: :class:`asyncio.BaseEventLoop` or :data:`None`
:param logger: Logger to use instead of the default logger
:type logger: :class:`logging.Logger` or :data:`None`
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
indefinitly 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
faliures.
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 compatiblity 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:: 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.
Only :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.
.. versionadded:: 0.8
.. 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_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)
@asyncio.coroutine
def _try_resume_stream_management(self, xmlstream, features):
try:
yield from self.stream.resume_sm(xmlstream)
except errors.StreamNegotiationFailure as exc:
self.logger.warn("failed to resume stream (%s)",
exc)
return False
return True
@asyncio.coroutine
def _negotiate_legacy_session(self):
self.logger.debug(
"remote server announces support for legacy sessions"
)
yield from self.stream._send_immediately(
stanza.IQ(type_=structs.IQType.SET,
payload=rfc3921.Session())
)
self.logger.debug(
"legacy session negotiated (upgrade your server!)"
)
@asyncio.coroutine
def _negotiate_stream(self, xmlstream, features):
server_can_do_sm = True
try:
features[nonza.StreamManagementFeature]
except KeyError:
if self.stream.sm_enabled:
self.logger.warn("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 = yield from 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")
yield from self._bind()
if server_can_do_sm:
self.logger.debug("attempting to start stream management")
try:
yield from 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:
yield from self._negotiate_legacy_session()
else:
self.logger.debug(
"skipping optional legacy session negotiation"
)
self.established_event.set()
yield from self.before_stream_established()
self.on_stream_established()
return features, resumed
@asyncio.coroutine
def _bind(self):
iq = stanza.IQ(type_=structs.IQType.SET)
iq.payload = rfc6120.Bind(resource=self._local_jid.resource)
try:
result = yield from 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)
@asyncio.coroutine
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 = \
yield from 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 = yield from self._negotiate_stream(
xmlstream,
features)
self._is_suspended = False
self._backoff_time = None
exc = yield from 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
yield from self.stream.close()
raise
finally:
self.logger.info("stopping stream")
self.stream.stop()
@asyncio.coroutine
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:
yield from 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)
yield from 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()
# services
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_)
instance = class_(
self,
logger_base=self.logger,
dependencies={
depclass: self._summon(depclass, visited)
for depclass in class_.PATCHED_ORDER_AFTER
}
)
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. Instanciate 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 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)
@asyncio.coroutine
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 = yield from 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 (yield from 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 neccessary 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
@asyncio.coroutine
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:
yield from asyncio.wait_for(
conn_future,
self._timeout.total_seconds(),
)
except asyncio.TimeoutError:
self._client.stop()
raise TimeoutError()
else:
yield from conn_future
return self._client.stream
@asyncio.coroutine
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:
yield from 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 00000044270 13415717537 0014256 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, stanza
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):
tag = feature_cls.TAG
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,
)
def __init__(self,
condition=errors.ErrorCondition.UNDEFINED_CONDITION,
**kwargs):
super().__init__(**kwargs)
self.condition = condition.to_xso()
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 13415717537 0013334 5 ustar 00root root 0000000 0000000 aioxmpp/pep/__init__.py 0000664 0000000 0000000 00000003353 13415717537 0015451 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
aioxmpp/pep/service.py 0000664 0000000 0000000 00000033676 13415717537 0015365 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)
@asyncio.coroutine
def available(self):
"""
Check whether we have a PEP identity associated with our account.
"""
disco_info = yield from 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
@asyncio.coroutine
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 (yield from 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)
def publish(self, node, data, *, id_=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.
:returns: The PubSub id of the published item or
:data:`None` if it is unknown.
:raises RuntimeError: if PEP is not supported.
If no `id_` is given it is generated by the server (and may be
returned).
"""
yield from self._check_for_pep()
return (yield from self._pubsub.publish(None, node, data, id_=id_))
class RegisteredPEPNode:
"""
Handle for registered PEP nodes.
*Never* instanciate 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):
"""
Wether 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 13415717537 0013505 5 ustar 00root root 0000000 0000000 aioxmpp/ping/__init__.py 0000664 0000000 0000000 00000003036 13415717537 0015620 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
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
from .xso import Ping
aioxmpp/ping/service.py 0000664 0000000 0000000 00000004573 13415717537 0015530 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)
@asyncio.coroutine
def handle_ping(self, request):
return ping_xso.Ping()
@asyncio.coroutine
def ping(self, 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.
.. 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.
"""
iq = aioxmpp.IQ(
to=peer,
type_=aioxmpp.IQType.GET,
payload=ping_xso.Ping()
)
yield from self.client.send(iq)
aioxmpp/ping/xso.py 0000664 0000000 0000000 00000002273 13415717537 0014674 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 13415717537 0014354 5 ustar 00root root 0000000 0000000 aioxmpp/presence/__init__.py 0000664 0000000 0000000 00000002735 13415717537 0016474 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
Service = PresenceClient # NOQA
aioxmpp/presence/service.py 0000664 0000000 0000000 00000034637 13415717537 0016403 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 recieved 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):
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.
.. .. note::
.. This was formerly handled by the :class:`aioxmpp.PresenceManagedClient`,
.. which is now merely a shim wrapper around :class:`aioxmpp.Client` and
.. :class:`PresenceServer`.
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
)
@asyncio.coroutine
def _before_stream_established(self):
if not self._state.available:
return True
yield from 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:`~.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 13415717537 0015102 5 ustar 00root root 0000000 0000000 aioxmpp/private_xml/__init__.py 0000664 0000000 0000000 00000002527 13415717537 0017221 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
from .service import PrivateXMLService # NOQA
aioxmpp/private_xml/service.py 0000664 0000000 0000000 00000004434 13415717537 0017121 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)
@asyncio.coroutine
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 (yield from self.client.send(iq))
@asyncio.coroutine
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)
)
yield from self.client.send(iq)
aioxmpp/private_xml/xso.py 0000664 0000000 0000000 00000003314 13415717537 0016266 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 00000104627 13415717537 0014775 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 inspect
import logging
import time
from enum import Enum
import xml.sax as sax
import xml.parsers.expat as pyexpat
from . import xml, errors, xso, nonza, stanza, callbacks, statemachine
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 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 is generic in the stream with which it is used. Currently, it is only
used with :class:`~.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 = callbacks.Signal()
on_deadtime_hard_limit_tripped = 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 immidately. 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 immidately. 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()
class XMLStream(asyncio.Protocol):
"""
XML stream implementation. This is an streaming :class:`asyncio.Protocol`
which translates the received bytes into XSOs.
`to` must be a domain :class:`~aioxmpp.JID` which identifies the
domain to which the stream shall connect.
`features_future` must be a :class:`asyncio.Future` instance; 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 mainly for unittesting purposes; this is an argument
to the :class:`~aioxmpp.xml.XMPPXMLGenerator` and slows down the XML
serialization, but produces deterministic results, which is important for
testing. Generally, it is preferred to leave this argument at its default.
`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.
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
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,
sorted_attributes=False,
base_logger=logging.getLogger("aioxmpp"),
loop=None):
self._to = to
self._sorted_attributes = sorted_attributes
self._logger = base_logger.getChild("XMLStream")
self._transport = None
self._features_future = features_future
self._exception = None
self._loop = loop or asyncio.get_event_loop()
self._error_futures = []
self._smachine = statemachine.OrderedStateMachine(State.READY)
self._transport_closing = False
self._monitor = 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 self._features_future:
self._features_future.set_exception(exc)
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):
self.stanza_parser.remove_class(nonza.StreamFeatures)
self._features_future.set_result(features)
self._features_future = None
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 as exc:
raise
except Exception as exc:
self._logger.exception(
"unexpected exception while parsing stanza"
" bubbled up through parser. stream so ded.")
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.warn("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
@asyncio.coroutine
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()
yield from 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: "jabber:client"},
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 occuring 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())
@asyncio.coroutine
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")
yield from 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
@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
@asyncio.coroutine
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 = yield from asyncio.wait(
[
fut,
failure_future,
],
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
loop=xmlstream._loop)
for other_fut in pending:
other_fut.cancel()
if fut in done:
return fut.result()
if failure_future in done:
failure_future.result()
raise TimeoutError()
@asyncio.coroutine
def reset_stream_and_get_features(xmlstream, timeout=None):
fut = asyncio.Future()
def cleanup():
xmlstream.stanza_parser.remove_class(nonza.StreamFeatures)
def receive(obj):
nonlocal fut
fut.set_result(obj)
cleanup()
failure_future = xmlstream.error_future()
xmlstream.stanza_parser.add_class(
nonza.StreamFeatures,
receive)
try:
xmlstream.reset()
done, pending = yield from asyncio.wait(
[
fut,
failure_future,
],
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
loop=xmlstream._loop)
for other_fut in pending:
other_fut.cancel()
if fut in done:
return fut.result()
if failure_future in done:
failure_future.result()
raise TimeoutError()
except: # NOQA
cleanup()
raise
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.warn("custom_condition argument to send_stream_error_and_close"
" not implemented")
xmlstream.close()
aioxmpp/pubsub/ 0000775 0000000 0000000 00000000000 13415717537 0014050 5 ustar 00root root 0000000 0000000 aioxmpp/pubsub/__init__.py 0000664 0000000 0000000 00000007657 13415717537 0016200 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()
"""
from .service import PubSubClient # NOQA
Service = PubSubClient
aioxmpp/pubsub/service.py 0000664 0000000 0000000 00000101074 13415717537 0016065 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_subscriptions
purge
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_subscriptions
.. automethod:: purge
Receiving notifications:
.. autosignal:: on_item_published(jid, node, item, *, message=None)
.. autosignal:: on_item_retracted(jid, node, id_, *, message=None)
.. autosignal:: on_node_deleted(jid, node, *, redirect_uri=None, message=None)
.. autosignal:: on_affiliation_update(jid, node, affiliation, *, message=None)
.. autosignal:: on_subscription_update(jid, node, state, *, subid=None, message=None)
.. 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.
"""
ORDER_AFTER = [
aioxmpp.DiscoClient,
]
on_item_published = aioxmpp.callbacks.Signal(doc=
"""
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.
""") # NOQA
on_item_retracted = aioxmpp.callbacks.Signal(doc=
"""
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.
""") # NOQA
on_node_deleted = aioxmpp.callbacks.Signal(doc=
"""
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.
""") # NOQA
on_affiliation_update = aioxmpp.callbacks.Signal(doc=
"""
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.
""") # NOQA
on_subscription_update = aioxmpp.callbacks.Signal(doc=
"""
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.s
""") # NOQA
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
@asyncio.coroutine
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 = yield from self._disco.query_info(jid)
result = set()
for feature in response.features:
try:
result.add(pubsub_xso.Feature(feature))
except ValueError:
continue
return result
@asyncio.coroutine
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 = yield from self.client.send(iq)
return response
@asyncio.coroutine
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)
)
yield from self.client.send(iq)
@asyncio.coroutine
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 overriden 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 = yield from self.client.send(iq)
return response.options.data
@asyncio.coroutine
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 overriden 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
yield from self.client.send(iq)
@asyncio.coroutine
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 = yield from self.client.send(iq)
return response.payload.data
@asyncio.coroutine
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 (yield from self.client.send(iq))
@asyncio.coroutine
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 (yield from self.client.send(iq))
@asyncio.coroutine
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 = yield from self.client.send(iq)
return response.payload
@asyncio.coroutine
def publish(self, jid, node, payload, *, id_=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`.
:raises aioxmpp.errors.XMPPError: as returned by the service
: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.
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
)
response = yield from 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_
@asyncio.coroutine
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`.
"""
yield from self.publish(jid, node, None)
@asyncio.coroutine
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
)
yield from self.client.send(iq)
@asyncio.coroutine
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 = yield from self.client.send(iq)
if response is not None and response.payload.node is not None:
return response.payload.node
return node
@asyncio.coroutine
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
)
)
)
yield from self.client.send(iq)
@asyncio.coroutine
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 = yield from 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
@asyncio.coroutine
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 (yield from self.client.send(iq))
@asyncio.coroutine
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 (yield from self.client.send(iq))
@asyncio.coroutine
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
]
)
)
)
yield from self.client.send(iq)
@asyncio.coroutine
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
]
)
)
)
yield from self.client.send(iq)
@asyncio.coroutine
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
)
)
)
yield from self.client.send(iq)
aioxmpp/pubsub/xso.py 0000664 0000000 0000000 00000075360 13415717537 0015246 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 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,
])
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,
])
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.
"""
aioxmpp/rfc3921.py 0000664 0000000 0000000 00000004005 13415717537 0014212 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 13415717537 0014214 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 negotation; :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 13415717537 0014066 5 ustar 00root root 0000000 0000000 aioxmpp/roster/__init__.py 0000664 0000000 0000000 00000003622 13415717537 0016202 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
Service = RosterClient # NOQA
aioxmpp/roster/service.py 0000664 0000000 0000000 00000062233 13415717537 0016106 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 aioxmpp.utils import namespaces
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 recevied 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 neccessary 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 neccessary 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)
@asyncio.coroutine
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
with (yield from 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)
@asyncio.coroutine
def _request_initial_roster(self):
iq = stanza.IQ(type_=structs.IQType.GET)
iq.payload = roster_xso.Query()
with (yield from 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 = yield from 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)
@asyncio.coroutine
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
])
yield from self.client.send(
stanza.IQ(
structs.IQType.SET,
payload=roster_xso.Query(items=[
item
])
),
timeout=timeout
)
@asyncio.coroutine
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.
"""
yield from 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 00000011551 13415717537 0015254 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 13415717537 0013351 5 ustar 00root root 0000000 0000000 aioxmpp/rsm/__init__.py 0000664 0000000 0000000 00000002552 13415717537 0015466 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 13415717537 0014551 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 00000006422 13415717537 0014070 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 mechansims.
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
@asyncio.coroutine
def _send_sasl_node_and_wait_for(self, node):
node = yield from 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
@asyncio.coroutine
def initiate(self, mechanism, payload=None):
with self.xmlstream.mute():
return (yield from self._send_sasl_node_and_wait_for(
nonza.SASLAuth(mechanism=mechanism,
payload=payload)))
@asyncio.coroutine
def respond(self, payload):
with self.xmlstream.mute():
return (yield from self._send_sasl_node_and_wait_for(
nonza.SASLResponse(payload=payload)
))
@asyncio.coroutine
def abort(self):
try:
next_state, payload = yield from 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 00000141774 13415717537 0016203 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
"""
import abc
import asyncio
import base64
import collections
import enum
import functools
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.
"""
@asyncio.coroutine
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
@asyncio.coroutine
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
@asyncio.coroutine
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()
@asyncio.coroutine
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
@asyncio.coroutine
def post_handshake(self, transport):
if self.deferred:
if self._post_handshake_deferred_failure is not None:
result = yield from 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:
yield from 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
overriden 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
@asyncio.coroutine
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 mechansim 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",
}
@asyncio.coroutine
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:
yield from 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
@asyncio.coroutine
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 mechansim.
: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
@asyncio.coroutine
def execute(self,
client_jid,
features,
xmlstream,
tls_transport):
client_jid = client_jid.bare()
password_signalled_abort = False
nattempt = 0
cached_credentials = None
@asyncio.coroutine
def credential_provider():
nonlocal password_signalled_abort, nattempt, cached_credentials
if cached_credentials is not None:
return client_jid.localpart, cached_credentials
password = yield from 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 = yield from 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
@asyncio.coroutine
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 (yield from 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 resued 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
@asyncio.coroutine
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 = yield from 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 = yield from 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,
no_verify=False):
"""
Construct a :class:`SecurityLayer`. Depending on the arguments passed,
different features are enabled or disabled.
:param password_provider: Source for the password to authenticate with.
:type password_provider: :class:`str` or coroutine
:param pin_store: Enable use of certificate pin store: if it is a
:class:`dict`, a new pin store is created (see `pin_type` argument) and
filled with the data from the dict. Otherwise, the given pin store is
used.
:type pin_store: :class:`dict` (compatible to
:meth:`~AbstractPinStore.import_from_json`) or
:class:`AbstractPinStore`
:param pin_type: Type of pin store to use with a dict passed to `pin_store`
(ignored if no dict is passed to `pin_store`).
:type pin_type: :class:`PinType`
:param post_handshake_deferred_failure: Coroutine to call when using pin
store and the certificate is not in the pin store and fails PKI
verification.
:type post_handshake_deferred_failure: coroutine
:param anonymous: trace token for SASL ANONYMOUS (:rfc:`4505`), enables
ANONYMOUS authentication
:type anonymous: :class:`str` or :data:`False`
:param no_verify: *Disable* all certificate verification. Usage is
**strongly discouraged** outside controlled test environments. See
below for alternatives.
:type no_verify: :class:`bool`
:raise RuntimeError: if `anonymous` is a :class:`str` and the version of
:mod:`aiosasl` in use does not provide :class:`aiosasl.ANONYMOUS`
:return: A new :class:`SecurityLayer` instance configured as per the
arguments.
`password_provider` must either be a coroutine or a :class:`str`. As a
coroutine, 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 it 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.
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.
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!
.. 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 versaility 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.
"""
if isinstance(password_provider, str):
static_password = password_provider
@asyncio.coroutine
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:
@asyncio.coroutine
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(
default_ssl_context,
certificate_verifier_factory,
True,
tuple(sasl_providers),
)
aioxmpp/service.py 0000664 0000000 0000000 00000133335 13415717537 0014572 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.servie.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.servie.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()
"""
import abc
import asyncio
import collections
import contextlib
import copy
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", set()
)
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 DependencyGraphNode:
def __init__(self):
self.class_ = None
def __repr__(self):
return "DependencyGraphNode({!r})".format(self.class_)
class DependencyGraph:
def __init__(self, edges=None, nodes=None):
if edges is None:
edges = []
if nodes is None:
nodes = []
self._edges = set(edges)
self._nodes = set(nodes)
def __deepcopy__(self, memo):
return DependencyGraph(self._edges, self._nodes)
def add_node(self, node):
self._nodes.add(node)
def add_edge(self, from_, to):
self._edges.add((from_, to))
def toposort(self):
edges = collections.defaultdict(lambda: set())
for from_, to in self._edges:
edges[from_].add(to)
sorted_ = []
marked = set()
done = set()
def visit(node):
if node in marked:
raise ValueError("dependency loop in service definitions")
if node in done:
return
done.add(node)
marked.add(node)
for dep in edges[node]:
visit(dep)
marked.remove(node)
sorted_.append(node)
for node in self._nodes:
visit(node)
return sorted_
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` explicitely 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 instanciated.
Thus, any service which occurs in :attr:`ORDER_BEFORE` will be
instanciated *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 instanciated
*before* the class which is being declraed.
Classes which are declared in this attribute are always instanciated
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
.. attribute:: _DEPGRAPH_NODE
An internal token used for topological ordering. Consider this
name reserved by the metaclass.
It is an error to manually define :attr:`_DEPGRAPH_NODE` 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` attribtes do not
change after class creation. In earlier versions they contained
the transitive completion of the dependency relation.
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 instanciated before ``Bar`` and ``Bar`` will
be instanciated before ``Foo``. There is no dependency relationship between
``Baz`` and ``Fourth``.
"""
__dependency_graph = DependencyGraph()
__service_order = {}
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:
raise TypeError(
"_DEPGRAPH_NODE 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"]
new_deps = copy.deepcopy(mcls.__dependency_graph)
depgraph_node = namespace["_DEPGRAPH_NODE"] = DependencyGraphNode()
new_deps.add_node(depgraph_node)
for cls in namespace["ORDER_AFTER"]:
new_deps.add_edge(depgraph_node, cls._DEPGRAPH_NODE)
for cls in namespace["ORDER_BEFORE"]:
new_deps.add_edge(cls._DEPGRAPH_NODE, depgraph_node)
sorted_ = new_deps.toposort()
mcls.__dependency_graph = new_deps
mcls.__service_order = dict(
(node, i) for i, node in enumerate(sorted_)
)
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 in new_handlers:
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)
)
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])
self._DEPGRAPH_NODE.class_ = 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 __lt__(self, other):
return (self.__service_order[self._DEPGRAPH_NODE] <
self.__service_order[other._DEPGRAPH_NODE])
def __le__(self, other):
return (self.__service_order[self._DEPGRAPH_NODE] <=
self.__service_order[other._DEPGRAPH_NODE])
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
instanciated. 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
.. automethod:: derive_logger
.. automethod:: shutdown
"""
def __init__(self, client, *, logger_base=None, dependencies={}):
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
for item in self.SERVICE_HANDLERS:
if isinstance(item, Descriptor):
item.add_to_stack(self, self.__context)
else:
(handler_cm, additional_args), obj = item
self.__context.enter_context(
handler_cm(
self,
self.__client.stream,
obj.__get__(self, type(self)),
*additional_args
)
)
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
@asyncio.coroutine
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`.
"""
@asyncio.coroutine
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.
"""
yield from 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 initalisation 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.
"""
def __new__(cls, key, is_unique=True, require_deps=()):
return super().__new__(cls, is_unique, key, frozenset(require_deps))
def add_handler_spec(f, handler_spec):
"""
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`
This uses a private attribute, whose exact name is an implementation
detail. The `handler_spec` is stored in a :class:`set` bound to the
attribute.
"""
automake_magic_attr(f).add(handler_spec)
def _apply_iq_handler(instance, stream, func, type_, payload_cls):
return aioxmpp.stream.iq_handler(stream, type_, payload_cls, func)
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,
type(instance),
)
def _apply_inbound_presence_filter(instance, stream, func):
return aioxmpp.stream.stanza_filter(
stream.service_inbound_presence_filter,
func,
type(instance),
)
def _apply_outbound_message_filter(instance, stream, func):
return aioxmpp.stream.stanza_filter(
stream.service_outbound_message_filter,
func,
type(instance),
)
def _apply_outbound_presence_filter(instance, stream, func):
return aioxmpp.stream.stanza_filter(
stream.service_outbound_presence_filter,
func,
type(instance),
)
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, type(instance))
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):
"""
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
: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_` and `payload_cls` 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
.. 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=()
)
)
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):
"""
Return true if `coro` has been decorated with :func:`iq_handler` for the
given `type_` and `payload_cls`.
"""
try:
handlers = get_magic_attr(coro)
except AttributeError:
return False
return HandlerSpec(
(_apply_iq_handler, (type_, payload_cls)),
) in handlers
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
return HandlerSpec(
(_apply_inbound_message_filter, ())
) 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
return HandlerSpec(
(_apply_inbound_presence_filter, ())
) 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
return HandlerSpec(
(_apply_outbound_message_filter, ())
) 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
return HandlerSpec(
(_apply_outbound_presence_filter, ())
) 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 13415717537 0013510 5 ustar 00root root 0000000 0000000 aioxmpp/shim/__init__.py 0000664 0000000 0000000 00000003547 13415717537 0015632 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
from .service import ( # NOQA
SHIMService,
)
aioxmpp/shim/service.py 0000664 0000000 0000000 00000005546 13415717537 0015534 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 advertisment.
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
)
@asyncio.coroutine
def _shutdown(self):
self._disco.unregister_feature(namespaces.xep0131_shim)
self._disco.unmount_node(namespaces.xep0131_shim)
yield from 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 13415717537 0014707 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 00000001622 13415717537 0016040 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
aioxmpp/stanza.py 0000664 0000000 0000000 00000077111 13415717537 0014431 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
"""
import base64
import enum
import random
import warnings
from . import xso, errors, structs
from .utils import namespaces
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 eight bytes of
random data, encoded as base64.
.. 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_ = "x"+base64.b64encode(random.getrandbits(
RANDOM_ID_BYTES * 8
).to_bytes(
RANDOM_ID_BYTES, "little"
)).decode("ascii")
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])
ext = xso.ChildMap([])
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
)
ext = xso.ChildMap([])
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 as exc:
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 00000014412 13415717537 0015571 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
@asyncio.coroutine
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))
yield from fut
@asyncio.coroutine
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))
yield from fut
aioxmpp/stream.py 0000664 0000000 0000000 00000272410 13415717537 0014423 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 errorr 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 orignial 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 time
import warnings
from datetime import datetime, timedelta
from enum import Enum
from . import (
stanza,
stanza as stanza_,
errors,
custom_queue,
nonza,
callbacks,
protocol,
structs,
ping,
)
from .utils import namespaces
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 occured.
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 guarenteed 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))
@asyncio.coroutine
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(loop=loop)
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.exception("broker task failed")
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 _iq_request_coro_done(self, request, task):
"""
Called when an IQ request handler coroutine returns. `request` holds
the IQ request which triggered the excecution of the coroutine and
`task` is the :class:`asyncio.Task` which tracks the running coroutine.
Compose a response and send that response.
"""
self._iq_request_tasks.remove(task)
try:
payload = task.result()
except errors.XMPPError as err:
response = request.make_reply(type_=structs.IQType.ERROR)
response.error = stanza.Error.from_exception(err)
except Exception:
response = request.make_reply(type_=structs.IQType.ERROR)
response.error = stanza.Error(
condition=errors.ErrorCondition.UNDEFINED_CONDITION,
type_=structs.ErrorType.CANCEL,
)
self._logger.exception("IQ request coroutine failed")
else:
response = request.make_reply(type_=structs.IQType.RESULT)
response.payload = payload
self._enqueue(response)
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 = 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
try:
awaitable = coro(stanza_obj)
except Exception as exc:
awaitable = asyncio.Future()
awaitable.set_exception(exc)
task = asyncio.ensure_future(awaitable)
task.add_done_callback(
functools.partial(
self._iq_request_coro_done,
stanza_obj))
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
)
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`.
"""
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 acoording 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 recieved 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 dervies 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):
"""
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
: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
prefer coroutine functions.
.. 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
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 guarenteed 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()
@asyncio.coroutine
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:
yield from self._task
except asyncio.CancelledError:
pass
@asyncio.coroutine
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
))
yield from self._xmlstream.close_and_wait() # does not raise
yield from 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()
@asyncio.coroutine
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 = yield from asyncio.wait(
[
active_fut,
incoming_fut,
],
return_when=asyncio.FIRST_COMPLETED,
timeout=timeout)
with (yield from 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")
if incoming_fut.done() and not incoming_fut.exception():
self._incoming_queue.putleft_nowait(incoming_fut.result())
else:
incoming_fut.cancel()
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
with (yield from 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()
@asyncio.coroutine
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 guarentees 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)
with (yield from self._broker_lock):
response = yield from 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 neccessary 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()
@asyncio.coroutine
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.
"""
if self.running:
raise RuntimeError("Cannot resume Stream Management while"
" StanzaStream is running")
self._start_prepare(xmlstream, self.recv_stanza)
try:
response = yield from 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):
xmlstream.stanza_parser.remove_class(
nonza.SMRequest)
xmlstream.stanza_parser.remove_class(
nonza.SMAcknowledgement)
self.stop_sm()
raise errors.StreamNegotiationFailure(
"Server rejected SM resumption")
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
for token in self._sm_unacked_list:
token._set_state(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)
@asyncio.coroutine
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 (yield from self.send(iq, timeout=timeout))
@asyncio.coroutine
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,
)
yield from self._enqueue(stanza)
@asyncio.coroutine
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"
)
yield from 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,
)
self._iq_response_map.add_listener(
(stanza.to, stanza.id_),
listener,
)
try:
yield from self._enqueue(stanza)
except Exception:
listener.cancel()
raise
if not timeout:
reply = yield from fut
else:
try:
reply = yield from asyncio.wait_for(
fut,
timeout=timeout
)
except asyncio.TimeoutError:
raise TimeoutError
return reply
@asyncio.coroutine
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):
"""
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
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.8
"""
stream.register_iq_request_handler(
type_,
payload_cls,
coro,
)
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 00000016376 13415717537 0015334 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
import 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 occuring 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 00000110100 13415717537 0014622 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)
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"])):
"""
A Jabber ID (JID). To construct a JID, either use the actual constructor,
or use the :meth:`fromstr` class method.
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.
"""
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):
"""
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):
"""
Obtain a :class:`JID` object by parsing a JID from the given string
`s`.
"""
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 lanugage 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 lanugage 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
mechansim 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)]
aioxmpp/tasks.py 0000664 0000000 0000000 00000016047 13415717537 0014257 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 00000065052 13415717537 0015172 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 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
GLOBAL_TIMEOUT_FACTOR = 1.0
_monotonic_info = time.get_clock_info("monotonic")
GLOBAL_TIMEOUT_FACTOR *= max(_monotonic_info.resolution, 0.0015) / 0.0015
logging.getLogger(__name__).debug("using GLOBAL_TIMEOUT_FACTOR = %.3f",
GLOBAL_TIMEOUT_FACTOR)
def get_timeout(base):
return base * GLOBAL_TIMEOUT_FACTOR
# FIXME: find a way to detect Travis CI and use 2.0 there, 1.0 otherwise.
# 1.0 is sufficient normally, but on Travis we sometimes get spurious failures.
DEFAULT_TIMEOUT = get_timeout(2.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:
# 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_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.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
@asyncio.coroutine
def __call__(self, *args, **kwargs):
result = super().__call__(*args, **kwargs)
yield from 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
@asyncio.coroutine
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)
@asyncio.coroutine
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 = yield from asyncio.wait(
[
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":
yield from self._write(*args)
elif action == "write_eof":
yield from self._write_eof(*args)
elif action == "close":
yield from self._close(*args)
elif action == "abort":
yield from self._abort(*args)
elif action == "starttls":
yield from 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
@asyncio.coroutine
def _write_eof(self):
self._basic("write_eof", self.WriteEof)
@asyncio.coroutine
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)
@asyncio.coroutine
def _abort(self):
self._basic("abort", self.Abort)
@asyncio.coroutine
def _close(self):
self._basic("close", self.Close)
@asyncio.coroutine
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:
yield from 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
@asyncio.coroutine
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)
)
yield from fut
class XMLStreamMock(InteractivityMock):
class Receive(collections.namedtuple("Receive", ["obj"])):
def do(self, xmlstream):
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 = []
def _execute_single(self, do):
do(self)
@asyncio.coroutine
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 = yield from asyncio.wait(
[
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":
yield from self._send_xso(*args)
elif action == "reset":
yield from self._reset(*args)
elif action == "close":
yield from self._close(*args)
elif action == "starttls":
yield from self._starttls(*args)
elif action == "abort":
yield from self._abort(*args)
elif action == "mute":
yield from self._mute(*args)
elif action == "unmute":
yield from self._unmute(*args)
else:
assert False
if self._done not in pending:
break
@asyncio.coroutine
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)
@asyncio.coroutine
def _reset(self):
self._basic("reset", self.Reset)
@asyncio.coroutine
def _mute(self):
self._basic("mute", self.Mute)
@asyncio.coroutine
def _unmute(self):
self._basic("unmute", self.Unmute)
@asyncio.coroutine
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)
@asyncio.coroutine
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)
@asyncio.coroutine
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:
yield from 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",))
@asyncio.coroutine
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)
)
yield from fut
@asyncio.coroutine
def close_and_wait(self):
fut = asyncio.Future()
self.on_closing.connect(fut, self.on_closing.AUTO_FUTURE)
self.close()
try:
yield from 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
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 00000040445 13415717537 0014733 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 overriden 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 occuring 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:
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 00000014571 13415717537 0014272 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 contextlib
import types
import lxml.etree as etree
__all__ = [
"etree",
"namespaces",
]
namespaces = types.SimpleNamespace()
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.bind = "urn:ietf:params:xml:ns:xmpp-bind"
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:
__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()
return super().__await__()
@asyncio.coroutine
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.errrors.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.
"""
# this late import is needed for Python 3.4
from aioxmpp import errors
todo = [asyncio.ensure_future(fut_or_coro) for fut_or_coro in fut_or_coros]
if not todo:
return []
yield from 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 errors.GatherError(message, exceptions)
return results
aioxmpp/vcard/ 0000775 0000000 0000000 00000000000 13415717537 0013647 5 ustar 00root root 0000000 0000000 aioxmpp/vcard/__init__.py 0000664 0000000 0000000 00000002435 13415717537 0015764 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
aioxmpp/vcard/service.py 0000664 0000000 0000000 00000005377 13415717537 0015675 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.xso as xso
import aioxmpp.service as service
from aioxmpp.utils import namespaces
from . import xso as vcard_xso
class VCardService(service.Service):
"""
Service for handling vcard-temp.
.. automethod:: get_vcard
.. automethod:: set_vcard
"""
@asyncio.coroutine
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 (yield from 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
@asyncio.coroutine
def set_vcard(self, vcard):
"""
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,
)
yield from self.client.send(iq)
aioxmpp/vcard/xso.py 0000664 0000000 0000000 00000006764 13415717537 0015047 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 13415717537 0014235 5 ustar 00root root 0000000 0000000 aioxmpp/version/__init__.py 0000664 0000000 0000000 00000002770 13415717537 0016354 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
VersionServer,
query_version,
)
aioxmpp/version/service.py 0000664 0000000 0000000 00000014356 13415717537 0016260 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 aioxmpp.utils import namespaces
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 possibily 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)
@asyncio.coroutine
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
@asyncio.coroutine
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 occured 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 (yield from stream.send(
aioxmpp.IQ(
type_=aioxmpp.IQType.GET,
to=target,
payload=version_xso.Query(),
)
))
aioxmpp/version/xso.py 0000664 0000000 0000000 00000003403 13415717537 0015420 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 00000114702 13415717537 0013727 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
"""
import copy
import ctypes
import ctypes.util
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
self._ns_map_stack.append(
(
self._curr_ns_map.copy(),
set(new_prefixes) - self._ns_auto_prefixes_floating_in,
old_counter
)
)
cleared_new_prefixes = dict(new_prefixes)
for uri, prefix in self._curr_ns_map.items():
try:
new_uri = cleared_new_prefixes[prefix]
except KeyError:
pass
else:
if new_uri == uri:
del cleared_new_prefixes[prefix]
self._curr_ns_map.update(new_decls)
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 unneccessary 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:
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.unparse_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.unparse_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.unparse_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 00000014311 13415717537 0015703 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 occurence 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 13415717537 0013361 5 ustar 00root root 0000000 0000000 aioxmpp/xso/__init__.py 0000664 0000000 0000000 00000041130 13415717537 0015471 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.
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.
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 SAX 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
--------------------------------------
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:: 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
--------------------
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:: Integer
.. autoclass:: Bool
.. autoclass:: DateTime
.. autoclass:: Date
.. autoclass:: Time
.. autoclass:: Base64Binary
.. autoclass:: HexBinary
.. autoclass:: JID
.. autoclass:: ConnectionLocation
.. autoclass:: LanguageTag
.. 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
-------------
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 faciliate the use.
.. autoclass:: AbstractTextChild
"""
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
Unknown,
AbstractCDataType,
AbstractElementType,
String,
Integer,
Float,
Bool,
DateTime,
Date,
Time,
Base64Binary,
HexBinary,
JID,
ConnectionLocation,
LanguageTag,
TextChildMap,
EnumType,
EnumCDataType,
EnumElementType,
AbstractValidator,
RestrictToSet,
Nmtoken,
IsInstance,
NumericRange,
)
from .model import ( # NOQA
tag_to_str,
normalize_tag,
UnknownChildPolicy,
UnknownAttrPolicy,
UnknownTextPolicy,
ValidateMode,
UnknownTopLevelTag,
Attr,
LangAttr,
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,
)
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
from .model import _PropBase
NO_DEFAULT = _PropBase.NO_DEFAULT
del _PropBase
from .query import ( # NOQA
where,
not_,
)
aioxmpp/xso/model.py 0000664 0000000 0000000 00000277347 13415717537 0015057 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):
"""
When assigned to a class’ attribute, it collects all character data of the
XML element.
Note that this destroys 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 Child(_ChildPropBase):
"""
When assigned to a class’ attribute, it collects any child which matches
any :attr:`XSO.TAG` of the given `classes`.
: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 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.
.. 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 instanciate 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.unparse_to_sax(dest)
class ChildList(_ChildPropBase):
"""
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.unparse_to_sax(dest)
class Collector(_PropBase):
"""
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 emtpy 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):
"""
Descriptor which represents an XML attribute.
When assigned to a class’ attribute, it binds that attribute to the XML
attribute with the given `tag`. `tag` must be a valid input to
:func:`normalize_tag`.
The following arguments occur at several of the descriptor classes, and are
all available at :class:`Attr`.
:param type_: A character data type to interpret the XML character data.
:type type_: :class:`~.xso.AbstractCDataType`
:param validator: An object which has a :meth:`validate` method. That
method receives a value which was either assigned to the property
(depending on the `validate` argument) or parsed from XML (after it
passed through `type_`).
:param validate: A value from the :class:`ValidateMode` enum, which defines
which values have to pass through the validator. At some points it
makes sense to only validate outgoing values, but be liberal with
incoming values. This defaults to :attr:`ValidateMode.FROM_RECV`.
:param default: The value which the attribute has if no value has been
assigned. This must be given to allow the attribute to be missing. It
defaults to a special value. If the attribute has not been assigned to
and `default` has not been set, accessing the attribute for reading
raises :class:`AttributeError`. An attribute with `default` value
is not emitted in the output.
:param missing: A callable which takes a :class:`Context` instance. It is
called whenever the attribute is missing (independent from the fact
whether it is required or not). The callable shall return a
not-:data:`None` value for the attribute to use. If the value is
:data:`None`, the usual handling of missing attributes takes place.
: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. Note that this is almost never the right
thing to do in XMPP.
.. 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):
"""
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):
"""
Represents the character data of a child element.
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 compatiblity 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):
"""
Represents a subset of the children of an XML element, as map.
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.unparse_to_sax(dest)
class ChildLangMap(ChildMap):
"""
Represents a subset of the children of an XML element, as map.
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):
"""
Represents a subset of the children of an XML element, as single value.
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):
"""
Represents the presence of a specific child element, as a boolean flag.
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):
"""
Represents a subset of the child elements as values. The value
representation is governed by the `type_` argument.
: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).unparse_to_sax(dest)
class ChildValueMap(_ChildPropBase):
"""
A mapping of keys to values, generated by child tags.
: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).unparse_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):
"""
A mapping of keys to lists of values, representing child tags.
: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`.
.. 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)).unparse_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):
"""
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.
For an example, see :class:`.Message`.
"""
def __init__(self, xso_type):
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:
prop.mark_incomplete(obj)
_mark_attributes_incomplete(attr_map.values(), obj)
logger.debug("while parsing XSO", 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:
logger.debug("while parsing XSO", 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:
logger.debug("while parsing XSO", 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:
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 = 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 = 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:: unparse_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 unparse_to_sax(self, dest):
# 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.unparse_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 overriden 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:
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):
nonlocal result
result = value
parser = aioxmpp.xso.XSOParser()
parser.add_class(Message, catch_result)
sd = aioxmpp.xso.SAXDriver(parser)
lxml.sax.saxify(lmxl.etree.fromstring(
""
))
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:
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()
aioxmpp/xso/query.py 0000664 0000000 0000000 00000027041 13415717537 0015104 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 00000111304 13415717537 0015077 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.
"""
import abc
import array
import base64
import binascii
import decimal
import ipaddress
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):
"""
Interpret the input value as string.
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):
"""
Parse the value as base-10 integer and return the result as :class:`int`.
"""
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):
"""
Parse the value as decimal float and return the result as :class:`float`.
"""
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):
"""
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):
"""
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):
"""
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):
"""
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):
"""
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):
"""
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):
"""
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):
"""
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()
addr, _, port = v.rpartition(":")
if not _:
raise ValueError("missing colon in connection location")
port = int(port)
if addr.startswith("[") and addr.endswith("]"):
addr = ipaddress.IPv6Address(addr[1:-1])
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):
"""
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 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 consturctor.
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 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,
)
value = self.nested_type.coerce(value)
try:
return self.enum_class(value)
except ValueError:
if self.pass_unknown:
return value
raise
if isinstance(value, self.enum_class):
return value
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 13415717537 0013210 5 ustar 00root root 0000000 0000000 benchmarks/__init__.py 0000664 0000000 0000000 00000001554 13415717537 0015326 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 13415717537 0015671 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 13415717537 0015430 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 13415717537 0012004 5 ustar 00root root 0000000 0000000 data/gen-features-enum.xsl 0000664 0000000 0000000 00000002121 13415717537 0016057 0 ustar 00root root 0000000 0000000
class Features(Enum):
"""
.. attribute::
:annotation: = ""
"""
= \
""
data/xep0060-features.xml 0000664 0000000 0000000 00000015277 13415717537 0015460 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 13415717537 0016116 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 13415717537 0012023 5 ustar 00root root 0000000 0000000 docs/CONTRIBUTING.rst 0000664 0000000 0000000 00000002744 13415717537 0014473 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 13415717537 0013475 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 00000002732 13415717537 0013516 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 13415717537 0013316 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 13415717537 0012574 5 ustar 00root root 0000000 0000000 docs/api/aioxmpp.rst 0000664 0000000 0000000 00000000030 13415717537 0014774 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp
docs/api/changelog.rst 0000664 0000000 0000000 00000215070 13415717537 0015262 0 ustar 00root root 0000000 0000000 .. _changelog:
Changelog
#########
.. _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 indentifying 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`muc ``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.)
.. _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 unparseable 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 accomodate 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 compatiblity.
* 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 13415717537 0014434 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 13415717537 0014410 5 ustar 00root root 0000000 0000000 docs/api/internal/cache.rst 0000664 0000000 0000000 00000000036 13415717537 0016204 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.cache
docs/api/internal/e2etest.rst 0000664 0000000 0000000 00000000040 13415717537 0016507 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.e2etest
docs/api/internal/index.rst 0000664 0000000 0000000 00000000536 13415717537 0016255 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
xml
docs/api/internal/network.rst 0000664 0000000 0000000 00000000040 13415717537 0016625 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.network
docs/api/internal/protocol.rst 0000664 0000000 0000000 00000000041 13415717537 0016776 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.protocol
docs/api/internal/statemachine.rst 0000664 0000000 0000000 00000000045 13415717537 0017606 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.statemachine
docs/api/internal/tasks.rst 0000664 0000000 0000000 00000000036 13415717537 0016266 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.tasks
docs/api/internal/xml.rst 0000664 0000000 0000000 00000000034 13415717537 0015737 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.xml
docs/api/public/ 0000775 0000000 0000000 00000000000 13415717537 0014052 5 ustar 00root root 0000000 0000000 docs/api/public/adhoc.rst 0000664 0000000 0000000 00000000036 13415717537 0015661 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.adhoc
docs/api/public/avatar.rst 0000664 0000000 0000000 00000000037 13415717537 0016062 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.avatar
docs/api/public/blocking.rst 0000664 0000000 0000000 00000000041 13415717537 0016367 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.blocking
docs/api/public/bookmarks.rst 0000664 0000000 0000000 00000000042 13415717537 0016570 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.bookmarks
docs/api/public/callbacks.rst 0000664 0000000 0000000 00000000042 13415717537 0016517 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.callbacks
docs/api/public/carbons.rst 0000664 0000000 0000000 00000000040 13415717537 0016225 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.carbons
docs/api/public/chatstates.rst 0000664 0000000 0000000 00000000043 13415717537 0016744 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.chatstates
docs/api/public/connector.rst 0000664 0000000 0000000 00000000042 13415717537 0016572 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.connector
docs/api/public/disco.rst 0000664 0000000 0000000 00000000036 13415717537 0015704 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.disco
docs/api/public/dispatcher.rst 0000664 0000000 0000000 00000000043 13415717537 0016727 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.dispatcher
docs/api/public/entitycaps.rst 0000664 0000000 0000000 00000000043 13415717537 0016764 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.entitycaps
docs/api/public/errors.rst 0000664 0000000 0000000 00000000037 13415717537 0016120 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.errors
docs/api/public/forms.rst 0000664 0000000 0000000 00000000036 13415717537 0015731 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.forms
docs/api/public/hashes.rst 0000664 0000000 0000000 00000000037 13415717537 0016057 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.hashes
docs/api/public/httpupload.rst 0000664 0000000 0000000 00000000043 13415717537 0016765 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.httpupload
docs/api/public/i18n.rst 0000664 0000000 0000000 00000000035 13415717537 0015361 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.i18n
docs/api/public/ibr.rst 0000664 0000000 0000000 00000000034 13415717537 0015355 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.ibr
docs/api/public/im.rst 0000664 0000000 0000000 00000000033 13415717537 0015205 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.im
docs/api/public/index.rst 0000664 0000000 0000000 00000003142 13415717537 0015713 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
im
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 13415717537 0015363 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.mdr
docs/api/public/misc.rst 0000664 0000000 0000000 00000000035 13415717537 0015535 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.misc
docs/api/public/muc.rst 0000664 0000000 0000000 00000000034 13415717537 0015365 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.muc
docs/api/public/node.rst 0000664 0000000 0000000 00000000035 13415717537 0015527 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.node
docs/api/public/nonza.rst 0000664 0000000 0000000 00000000036 13415717537 0015730 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.nonza
docs/api/public/pep.rst 0000664 0000000 0000000 00000000034 13415717537 0015365 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.pep
docs/api/public/ping.rst 0000664 0000000 0000000 00000000035 13415717537 0015537 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.ping
docs/api/public/presence.rst 0000664 0000000 0000000 00000000041 13415717537 0016403 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.presence
docs/api/public/private_xml.rst 0000664 0000000 0000000 00000000044 13415717537 0017134 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.private_xml
docs/api/public/pubsub.rst 0000664 0000000 0000000 00000000037 13415717537 0016104 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.pubsub
docs/api/public/rfc3921.rst 0000664 0000000 0000000 00000000040 13415717537 0015667 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.rfc3921
docs/api/public/rfc6120.rst 0000664 0000000 0000000 00000000040 13415717537 0015661 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.rfc6120
docs/api/public/roster.rst 0000664 0000000 0000000 00000000037 13415717537 0016122 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.roster
docs/api/public/rsm.rst 0000664 0000000 0000000 00000000034 13415717537 0015402 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.rsm
docs/api/public/sasl.rst 0000664 0000000 0000000 00000000035 13415717537 0015544 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.sasl
docs/api/public/security_layer.rst 0000664 0000000 0000000 00000000047 13415717537 0017650 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.security_layer
docs/api/public/service.rst 0000664 0000000 0000000 00000000040 13415717537 0016236 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.service
docs/api/public/shim.rst 0000664 0000000 0000000 00000000035 13415717537 0015542 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.shim
docs/api/public/stanza.rst 0000664 0000000 0000000 00000000037 13415717537 0016104 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.stanza
docs/api/public/stream.rst 0000664 0000000 0000000 00000000037 13415717537 0016077 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.stream
docs/api/public/structs.rst 0000664 0000000 0000000 00000000040 13415717537 0016305 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.structs
docs/api/public/tracking.rst 0000664 0000000 0000000 00000000041 13415717537 0016401 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.tracking
docs/api/public/vcard.rst 0000664 0000000 0000000 00000000036 13415717537 0015702 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.vcard
docs/api/public/xso.rst 0000664 0000000 0000000 00000000034 13415717537 0015412 0 ustar 00root root 0000000 0000000 .. automodule:: aioxmpp.xso
docs/api/stability.rst 0000664 0000000 0000000 00000011514 13415717537 0015334 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 13415717537 0015376 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 00000022744 13415717537 0013333 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.autosummary',
'aioxmppspecific']
# 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 – 2016, Jonas Wielicki'
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 Wielicki', '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 Wielicki'], 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 Wielicki', '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 13415717537 0013674 5 ustar 00root root 0000000 0000000 docs/dev-guide/index.rst 0000664 0000000 0000000 00000013246 13415717537 0015543 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 overriden 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_verfiy`` 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 00000003561 13415717537 0014425 0 ustar 00root root 0000000 0000000 Glossary
########
This section defines terms used throughout the :mod:`aioxmpp` documentation.
.. glossary::
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 Member
Representation of an entity which takes part in a :term:`Conversation`. The
actual definition of "taking part in a conversation" depends on the
specific medium used.
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 `_.
docs/index.rst 0000664 0000000 0000000 00000016410 13415717537 0013666 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
.. _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:`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:`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:`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
avaliable 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:`333` (Chat Markers), schema-only, 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 Wielicki
`_, 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 13415717537 0013630 5 ustar 00root root 0000000 0000000 docs/licenses/apache20.txt 0000664 0000000 0000000 00000025142 13415717537 0015760 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 13415717537 0016423 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 13415717537 0015746 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 13415717537 0015346 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 13415717537 0016542 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 13415717537 0015610 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 13415717537 0016475 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 13415717537 0015426 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 13415717537 0016173 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 13415717537 0014243 5 ustar 00root root 0000000 0000000 docs/sphinx-data/.gitignore 0000664 0000000 0000000 00000000015 13415717537 0016227 0 ustar 00root root 0000000 0000000 static
build
docs/sphinx-data/extensions/ 0000775 0000000 0000000 00000000000 13415717537 0016442 5 ustar 00root root 0000000 0000000 docs/sphinx-data/extensions/aioxmppspecific.py 0000664 0000000 0000000 00000013320 13415717537 0022176 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 PyModulelevel, PyClassmember
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 PyCoroutineMixin(object):
def handle_signature(self, sig, signode):
ret = super(PyCoroutineMixin, self).handle_signature(sig, signode)
signode.insert(0, addnodes.desc_annotation('coroutine ', 'coroutine '))
return ret
class PyCoroutineFunction(PyCoroutineMixin, PyModulelevel):
def run(self):
self.name = 'py:function'
return PyModulelevel.run(self)
class PyCoroutineMethod(PyCoroutineMixin, PyClassmember):
def run(self):
self.name = 'py:method'
return PyClassmember.run(self)
class CoroutineAwareFunctionDocumenter(FunctionDocumenter):
objtype = 'function'
priority = 3
def import_object(self):
ret = ModuleLevelDocumenter.import_object(self)
if not ret:
return ret
if asyncio.iscoroutinefunction(self.object):
self.directivetype = "coroutinefunction"
return ret
class DecoratorDocumenter(FunctionDocumenter):
objtype = 'decorator'
priority = 3
class CoroutineAwareMethodDocumenter(MethodDocumenter):
objtype = 'method'
priority = 4
def import_object(self):
ret = super().import_object()
if not ret:
return ret
if (self.directivetype == "method" and
asyncio.iscoroutinefunction(self.object)):
self.directivetype = "coroutinemethod"
return ret
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(PyClassmember):
def handle_signature(self, sig, signode):
ret = super(PySignal, self).handle_signature(sig, signode)
signode.insert(0, addnodes.desc_annotation('signal ', 'signal '))
return ret
def run(self):
self.name = 'py:method'
return PyClassmember.run(self)
class PySyncSignal(PyClassmember):
def handle_signature(self, sig, signode):
ret = super(PySyncSignal, self).handle_signature(sig, signode)
signode.insert(0, addnodes.desc_annotation('coroutine signal ', 'coroutine signal '))
return ret
def run(self):
self.name = 'py:method'
return PyClassmember.run(self)
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, '')]
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', 'coroutinefunction', PyCoroutineFunction)
app.add_directive_to_domain('py', 'coroutinemethod', PyCoroutineMethod)
app.add_directive_to_domain('py', 'signal', PySignal)
app.add_directive_to_domain('py', 'syncsignal', PySyncSignal)
app.add_autodocumenter(CoroutineAwareFunctionDocumenter)
app.add_autodocumenter(CoroutineAwareMethodDocumenter)
app.add_autodocumenter(SignalAwareMethodDocumenter)
app.add_autodocumenter(DecoratorDocumenter)
return {'version': '1.0', 'parallel_read_safe': True}
docs/user-guide/ 0000775 0000000 0000000 00000000000 13415717537 0014074 5 ustar 00root root 0000000 0000000 docs/user-guide/index.rst 0000664 0000000 0000000 00000000243 13415717537 0015734 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
docs/user-guide/installation.rst 0000664 0000000 0000000 00000011517 13415717537 0017334 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 neccessary 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 00000013557 13415717537 0016457 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 occured.
.. 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
mechansim. 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 13415717537 0017024 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 13415717537 0014536 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 13415717537 0012711 5 ustar 00root root 0000000 0000000 examples/.gitignore 0000664 0000000 0000000 00000000031 13415717537 0014673 0 ustar 00root root 0000000 0000000 config.ini
pinstore.json
examples/Makefile 0000664 0000000 0000000 00000000377 13415717537 0014360 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 00000011114 13415717537 0014376 0 ustar 00root root 0000000 0000000 aioxmpp Examples
################
Most of these examples are built ontop 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 13415717537 0015532 5 ustar 00root root 0000000 0000000 examples/adhoc_browser/__main__.py 0000664 0000000 0000000 00000002767 13415717537 0017640 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 00000022677 13415717537 0017564 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
@asyncio.coroutine
def run_with_session_fut(self, name, session_fut):
self.setWindowTitle("{} - {}".format(
name,
self.base_title
))
self.show()
try:
self.session = yield from 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
@asyncio.coroutine
def _submit_action(self, type_):
yield from 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
def closeEvent(self, ev):
if self.session is not None:
yield from self.session.close()
return super().closeEvent(ev)
examples/adhoc_browser/main.py 0000664 0000000 0000000 00000015254 13415717537 0017037 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.ui.commands.setModel(self.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
@asyncio.coroutine
def command_activated(self, index):
if not index.isValid():
return
jid = aioxmpp.JID.fromstr(
self.commands_model.data(
self.commands_model.index(
index.row(),
self.disco_model.COLUMN_JID,
index.parent(),
),
Qt.Qt.DisplayRole)
)
node = self.commands_model.data(
self.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
@asyncio.coroutine
def scan(self, *args, **kwargs):
jid = aioxmpp.JID.fromstr(self.ui.target_jid.text())
items = yield from self.disco_svc.query_items(
jid
)
commands = yield from 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)
@asyncio.coroutine
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:
yield from 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)
)
@asyncio.coroutine
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)
cm = self.client.connected()
aexit = cm.__aexit__
yield from cm.__aenter__()
try:
task = asyncio.ensure_future(self._main())
yield from task
except:
if not (yield from aexit(*sys.exc_info())):
raise
else:
yield from aexit(None, None, None)
examples/adhoc_browser/ui/ 0000775 0000000 0000000 00000000000 13415717537 0016147 5 ustar 00root root 0000000 0000000 examples/adhoc_browser/ui/.gitignore 0000664 0000000 0000000 00000000005 13415717537 0020132 0 ustar 00root root 0000000 0000000 *.py
examples/adhoc_browser/ui/form.ui 0000664 0000000 0000000 00000006006 13415717537 0017453 0 ustar 00root root 0000000 0000000
FormDialog00575592Execute Ad-Hoc CommandTextLabel00Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse75trueTextLabelTextLabelQt::Horizontal4020PreviousNextfalseCompleteCancelClose
examples/adhoc_browser/ui/main.ui 0000664 0000000 0000000 00000007112 13415717537 0017433 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 13415717537 0017247 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 00000007420 13415717537 0015211 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
@asyncio.coroutine
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 = yield from 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:
yield from self.blocking.block_jids(self.args.jids_to_block)
else:
print("nothing to block")
if self.args.jids_to_unblock:
yield from 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 00000004732 13415717537 0016434 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
)))
@asyncio.coroutine
def run_example(self):
self.stop_event = self.make_sigint_event()
yield from super().run_example()
@asyncio.coroutine
def run_simple_example(self):
filterchain = self.client.stream.app_inbound_message_filter
with filterchain.context_register(self._message_filter):
print("enabling carbons")
yield from self.carbons.enable()
print("carbons enabled! sniffing ... (hit Ctrl+C to stop)")
yield from self.stop_event.wait()
if __name__ == "__main__":
exec_example(CarbonsSniffer())
examples/echo_bot.py 0000664 0000000 0000000 00000003540 13415717537 0015047 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)
@asyncio.coroutine
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)")
yield from stop_event.wait()
if __name__ == "__main__":
exec_example(EchoBot())
examples/entity_info.py 0000664 0000000 0000000 00000007013 13415717537 0015613 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"
)
@asyncio.coroutine
def run_simple_example(self):
disco = self.client.summon(aioxmpp.DiscoClient)
try:
info = yield from 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 00000004036 13415717537 0016003 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="?",
)
@asyncio.coroutine
def run_simple_example(self):
disco = self.client.summon(aioxmpp.DiscoClient)
try:
items = yield from 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 00000015563 13415717537 0015272 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
@asyncio.coroutine
def run_simple_example(self):
raise NotImplementedError(
"run_simple_example must be overriden if run_example isn’t"
)
@asyncio.coroutine
def run_example(self):
self.client = self.make_simple_client()
cm = self.client.connected()
aexit = cm.__aexit__
yield from cm.__aenter__()
try:
yield from self.run_simple_example()
except:
if not (yield from aexit(*sys.exc_info())):
raise
else:
yield from aexit(None, None, None)
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_muc_config.py 0000664 0000000 0000000 00000005034 13415717537 0016235 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
@asyncio.coroutine
def run_simple_example(self):
config = yield from 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 00000004650 13415717537 0015226 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
@asyncio.coroutine
def run_simple_example(self):
vcard = yield from self.vcard.get_vcard(
self.remote_jid
)
es = lxml.etree.tostring(vcard.elements, pretty_print=True,
encoding="utf-8")
print(es.decode("utf-8"))
@asyncio.coroutine
def run_example(self):
yield from super().run_example()
if __name__ == "__main__":
exec_example(VCard())
examples/ibr_test.py 0000664 0000000 0000000 00000006020 13415717537 0015074 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 00000003704 13415717537 0017261 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)
)
@asyncio.coroutine
def run_simple_example(self):
adhoc = self.client.summon(aioxmpp.adhoc.AdHocClient)
for item in (yield from 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 00000005740 13415717537 0016130 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
@asyncio.coroutine
def run_simple_example(self):
print("collecting presences... ")
self.presences = yield from self.collector.done_future
@asyncio.coroutine
def run_example(self):
yield from 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 00000005035 13415717537 0015430 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
@asyncio.coroutine
def run_example(self):
self.stop_event = self.make_sigint_event()
yield from super().run_example()
@asyncio.coroutine
def run_simple_example(self):
yield from self.stop_event.wait()
if __name__ == "__main__":
exec_example(ListenPEP())
examples/muc_logger.py 0000664 0000000 0000000 00000013103 13415717537 0015404 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,
))
@asyncio.coroutine
def run_example(self):
self.stop_event = self.make_sigint_event()
yield from super().run_example()
@asyncio.coroutine
def run_simple_example(self):
print("waiting to join room...")
done, pending = yield from 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()
yield from self.stop_event.wait()
if __name__ == "__main__":
exec_example(MucLogger())
examples/presence_info.py 0000664 0000000 0000000 00000007110 13415717537 0016101 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.
"""
@asyncio.coroutine
def _show_info(self, full_jid):
info = yield from 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
@asyncio.coroutine
def run_simple_example(self):
for i in range(5, 0, -1):
print("going to wait {} more seconds for further "
"presence".format(i))
yield from asyncio.sleep(1)
if __name__ == "__main__":
exec_example(PresenceInfo())
examples/query_versions.py 0000664 0000000 0000000 00000007751 13415717537 0016372 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
@asyncio.coroutine
def run_example(self):
self.stop_event = self.make_sigint_event()
yield from 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,
)
@asyncio.coroutine
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())
yield from 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 13415717537 0017323 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 13415717537 0022347 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 13415717537 0015116 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 00000005527 13415717537 0016457 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
@asyncio.coroutine
def run_simple_example(self):
metadata = yield from self.avatar.get_avatar_metadata(
self.remote_jid
)
for metadatum in metadata:
if metadatum.can_get_image_bytes_via_xmpp:
image = yield from 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")
@asyncio.coroutine
def run_example(self):
yield from super().run_example()
if __name__ == "__main__":
exec_example(Avatar())
examples/roster.py 0000664 0000000 0000000 00000004713 13415717537 0014606 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
@asyncio.coroutine
def run_simple_example(self):
done, pending = yield from asyncio.wait(
[
self.sigint_event.wait(),
self.done_event.wait()
],
return_when=asyncio.FIRST_COMPLETED,
)
for fut in pending:
fut.cancel()
@asyncio.coroutine
def run_example(self):
self.sigint_event = self.make_sigint_event()
yield from super().run_example()
if __name__ == "__main__":
exec_example(Roster())
examples/send_message.py 0000664 0000000 0000000 00000003563 13415717537 0015727 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!)",
)
@asyncio.coroutine
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 ...")
yield from self.client.send(msg)
print("message sent!")
if __name__ == "__main__":
exec_example(SendMessage())
examples/send_raw.py 0000664 0000000 0000000 00000003533 13415717537 0015071 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",
)
@asyncio.coroutine
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 ...")
yield from self.client.send(msg)
print("message sent!")
if __name__ == "__main__":
exec_example(SendMessage())
examples/server_info.py 0000664 0000000 0000000 00000004255 13415717537 0015612 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):
@asyncio.coroutine
def run_simple_example(self):
disco = self.client.summon(aioxmpp.DiscoClient)
try:
info = yield from 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 00000004723 13415717537 0015422 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
@asyncio.coroutine
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)
yield from self.avatar.publish_avatar_set(avatar_set)
elif self.wipe_avatar:
yield from self.avatar.disable_avatar()
@asyncio.coroutine
def run_example(self):
yield from super().run_example()
if __name__ == "__main__":
exec_example(Avatar())
examples/set_muc_config.py 0000664 0000000 0000000 00000011231 13415717537 0016245 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
@asyncio.coroutine
def run_simple_example(self):
muc = self.client.summon(aioxmpp.MUCClient)
config = yield from 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
yield from muc.set_room_config(
self.muc_jid,
form.render_reply()
)
if __name__ == "__main__":
exec_example(ServerInfo())
examples/upload.py 0000664 0000000 0000000 00000015356 13415717537 0014561 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,
))
@asyncio.coroutine
def _check_for_upload_service(self, disco, jid):
info = yield from 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 response.status not in (200, 201):
print(
"error: upload failed: {}".format(response.reason),
file=sys.stderr,
)
return False
return True
@asyncio.coroutine
def run_simple_example(self):
if not self.service_addr:
disco = self.client.summon(aioxmpp.DiscoClient)
items = yield from 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,
(yield from 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 = yield from 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 (yield from 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 13415717537 0015561 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 00000005141 13415717537 0012606 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")
install_requires = [
'aiosasl>=0.3', # need 0.2+ for LGPLv3
'aioopenssl>=0.1',
'babel~=2.3',
'dnspython~=1.0',
'lxml~=4.0',
'multidict<5,>=2.0',
'sortedcollections>=0.5',
'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 Wielicki",
author_email="jonas@wielicki.name",
license="LGPLv3+",
classifiers=[
"Development Status :: 3 - Alpha",
"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.3",
"Programming Language :: Python :: 3.4",
"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 13415717537 0012235 5 ustar 00root root 0000000 0000000 tests/__init__.py 0000664 0000000 0000000 00000002240 13415717537 0014344 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 13415717537 0013313 5 ustar 00root root 0000000 0000000 tests/adhoc/__init__.py 0000664 0000000 0000000 00000001555 13415717537 0015432 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 00000016074 13415717537 0015407 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
@asyncio.coroutine
def setUp(self, commands_providers):
services = [aioxmpp.AdHocClient]
self.peers = commands_providers
self.client, = yield from asyncio.gather(
self.provisioner.get_connected_client(
services=services,
),
)
self.svc = self.client.summon(aioxmpp.AdHocClient)
@asyncio.coroutine
def _get_ping_peer(self):
for peer in self.peers:
for item in (yield from 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
@asyncio.coroutine
def test_get_list(self):
items = []
for peer in self.peers:
items.extend((yield from self.svc.get_commands(
peer
)))
self.assertTrue(items)
@skip_with_quirk(Quirk.NO_ADHOC_PING)
@blocking_timed
@asyncio.coroutine
def test_ping(self):
ping_peer = yield from self._get_ping_peer()
session = yield from self.svc.execute(ping_peer, "ping")
self.assertTrue(session.response.notes)
yield from session.close()
@skip_with_quirk(Quirk.NO_ADHOC_PING)
@blocking_timed
@asyncio.coroutine
def test_ping_with_async_cm(self):
# TODO: port this to python 3.5+ once we require its
ping_peer = yield from self._get_ping_peer()
session = yield from self.svc.execute(ping_peer, "ping")
yield from session.__aenter__()
self.assertTrue(session.response.notes)
yield from session.__aexit__(None, None, None)
class TestAdHocServer(TestCase):
@blocking
@asyncio.coroutine
def setUp(self):
self.client, self.server = yield from 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)
@asyncio.coroutine
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
@asyncio.coroutine
def test_advertises_command_support(self):
self.assertTrue(
(yield from self.client_svc.supports_commands(
self.server.local_jid,
))
)
@blocking_timed
@asyncio.coroutine
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 = yield from 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
@asyncio.coroutine
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 = yield from 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
@asyncio.coroutine
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 = yield from 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)
yield from session.close()
@blocking_timed
@asyncio.coroutine
def test_properly_fail_for_unknown_command(self):
with self.assertRaises(aioxmpp.XMPPCancelError) as ctx:
session = yield from 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 00000111025 13415717537 0016364 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.assertLess(
aioxmpp.disco.DiscoClient,
adhoc_service.AdHocClient,
)
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") 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") 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 13415717537 0015543 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 13415717537 0013513 5 ustar 00root root 0000000 0000000 tests/avatar/__init__.py 0000664 0000000 0000000 00000001554 13415717537 0015631 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 00000021615 13415717537 0015604 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
@asyncio.coroutine
def setUp(self):
self.client, = yield from asyncio.gather(
self.provisioner.get_connected_client(
services=[
aioxmpp.EntityCapsService,
aioxmpp.PresenceServer,
aioxmpp.avatar.AvatarService,
]
),
)
@blocking_timed
@asyncio.coroutine
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)
yield from avatar_impl.publish_avatar_set(avatar_set)
avatar_info = yield from avatar_impl.get_avatar_metadata(
self.client.local_jid.bare()
)
self.assertEqual(
len(avatar_info),
1
)
info = avatar_info[0]
test_image_retrieved = yield from info.get_image_bytes()
self.assertEqual(
test_image_retrieved,
TEST_IMAGE
)
@blocking_timed
@asyncio.coroutine
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")
yield from avatar_impl.publish_avatar_set(avatar_set)
logging.info("waiting for completion")
jid, metadata = yield from done_future
self.assertEqual(jid, self.client.local_jid.bare())
self.assertEqual(len(metadata), 1)
tests/avatar/test_service.py 0000664 0000000 0000000 00000144236 13415717537 0016576 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.assertGreater(
avatar_service.AvatarService,
aioxmpp.DiscoClient,
)
self.assertGreater(
avatar_service.AvatarService,
aioxmpp.DiscoServer,
)
self.assertGreater(
avatar_service.AvatarService,
aioxmpp.PubSubClient,
)
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 13415717537 0015754 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 13415717537 0014025 5 ustar 00root root 0000000 0000000 tests/blocking/__init__.py 0000664 0000000 0000000 00000001554 13415717537 0016143 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 00000010000 13415717537 0016100 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
@asyncio.coroutine
def make_client(self, run_before=None):
return (yield from self.provisioner.get_connected_client(
services=[
aioxmpp.DiscoClient,
aioxmpp.BlockingClient,
],
prepare=run_before,
))
@blocking_timed
@asyncio.coroutine
def test_blocklist(self):
initial_future = asyncio.Future()
block_future = asyncio.Future()
unblock_future = asyncio.Future()
unblock_all_future = asyncio.Future()
@asyncio.coroutine
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 = yield from self.make_client(connect_initial_signal)
blocking = client.summon(aioxmpp.BlockingClient)
logging.info("waiting for initial blocklist")
initial_blocklist = yield from 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
)
yield from blocking.block_jids([TEST_JID1, TEST_JID2, TEST_JID3])
logging.info("waiting for update on block")
blocked_jids = yield from 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
)
yield from blocking.unblock_jids([TEST_JID1])
logging.info("waiting for update on unblock")
unblocked_jids = yield from 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
)
yield from blocking.unblock_all()
logging.info("waiting for update on unblock all")
unblocked_all_jids = yield from 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 00000034130 13415717537 0017077 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.assertGreater(
blocking.BlockingClient,
aioxmpp.DiscoClient
)
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 13415717537 0016252 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@exmaple.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 13415717537 0014225 5 ustar 00root root 0000000 0000000 tests/bookmarks/__init__.py 0000664 0000000 0000000 00000001554 13415717537 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
# .
#
########################################################################
tests/bookmarks/test_e2e.py 0000664 0000000 0000000 00000011771 13415717537 0016320 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,
TestCase,
)
class TestBookmarks(TestCase):
@blocking_timed
@asyncio.coroutine
def setUp(self):
self.client = yield from self.provisioner.get_connected_client(
services=[aioxmpp.BookmarkClient]
)
self.s = self.client.summon(aioxmpp.BookmarkClient)
@blocking_timed
@asyncio.coroutine
def test_store_and_retrieve(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
yield from self.s.add_bookmark(bookmark)
bookmarks = yield from self.s.get_bookmarks()
self.assertIn(bookmark, bookmarks)
self.assertIsNot(bookmark, bookmarks[0])
@blocking_timed
@asyncio.coroutine
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)
yield from self.s.add_bookmark(bookmark)
self.assertTrue(added_future.done())
self.assertEqual(added_future.result(), bookmark)
self.assertIsNot(added_future.result(), bookmark)
@blocking_timed
@asyncio.coroutine
def test_store_and_remove(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
yield from self.s.add_bookmark(bookmark)
yield from self.s.discard_bookmark(bookmark)
bookmarks = yield from self.s.get_bookmarks()
self.assertNotIn(bookmark, bookmarks)
@blocking_timed
@asyncio.coroutine
def test_remove_event(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
yield from 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)
yield from self.s.discard_bookmark(bookmark)
self.assertTrue(removed_future.done())
self.assertEqual(removed_future.result(), bookmark)
self.assertIsNot(removed_future.result(), bookmark)
@blocking_timed
@asyncio.coroutine
def test_store_and_update(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
yield from self.s.add_bookmark(bookmark)
updated_bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit"),
nick="firstwitch",
autojoin=True,
)
yield from self.s.update_bookmark(bookmark, updated_bookmark)
bookmarks = yield from self.s.get_bookmarks()
self.assertNotIn(bookmark, bookmarks)
self.assertIn(updated_bookmark, bookmarks)
@blocking_timed
@asyncio.coroutine
def test_change_event(self):
bookmark = aioxmpp.bookmarks.Conference(
"Coven",
aioxmpp.JID.fromstr("coven@chat.shakespeare.lit")
)
yield from 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,
)
yield from 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 00000060251 13415717537 0017302 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
@asyncio.coroutine
def get_private_xml(self, xso):
payload = copy.deepcopy(
self.stored.setdefault(xso.TAG[0], copy.deepcopy(xso))
)
return aioxmpp.private_xml.Query(payload)
@asyncio.coroutine
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
@asyncio.coroutine
def test_private_xml():
nonlocal before, after
before = (
yield from self.private_xml.get_private_xml(ExampleXSO())
).registered_payload
yield from self.private_xml.set_private_xml(ExampleXSO("foo"))
after = (
yield from 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):
@asyncio.coroutine
def test_private_xml():
yield from self.private_xml.set_private_xml(ExampleXSO("foo"))
return (
yield from 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
@asyncio.coroutine
def test_private_xml():
for i in range(5):
yield from self.private_xml.set_private_xml(ExampleXSO("foo"))
results.append((
yield from 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 13415717537 0016445 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 13415717537 0013664 5 ustar 00root root 0000000 0000000 tests/carbons/__init__.py 0000664 0000000 0000000 00000001554 13415717537 0016002 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 00000014073 13415717537 0016742 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.assertLess(
aioxmpp.DiscoClient,
carbons_service.CarbonsClient,
)
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 13415717537 0016123 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 13415717537 0014400 5 ustar 00root root 0000000 0000000 tests/chatstates/__init__.py 0000664 0000000 0000000 00000001554 13415717537 0016516 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 13415717537 0017156 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 13415717537 0016622 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 13415717537 0013336 5 ustar 00root root 0000000 0000000 tests/disco/__init__.py 0000664 0000000 0000000 00000001554 13415717537 0015454 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 13415717537 0016515 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 00000176422 13415717537 0016423 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
),
unittest.mock.call.stream.register_iq_request_handler(
structs.IQType.GET,
disco_xso.ItemsQuery,
s.handle_items_request
)
],
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
@asyncio.coroutine
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
@asyncio.coroutine
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 13415717537 0015566 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 13415717537 0014420 5 ustar 00root root 0000000 0000000 tests/entitycaps/__init__.py 0000664 0000000 0000000 00000001554 13415717537 0016536 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 13415717537 0017600 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 00000066413 13415717537 0017220 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