pax_global_header00006660000000000000000000000064141601462130014510gustar00rootroot0000000000000052 comment=8c6ed4d73614e0881d8189120cadeabbe7a083e5 .coveragerc000066400000000000000000000003071416014621300131750ustar00rootroot00000000000000[run] relative_files = True source= aioxmpp omit= aioxmpp/benchtest/* aioxmpp/e2etest/* aioxmpp/_ssl_transport.py */python?.?/* */python?.?-dev/* */dist-packages/* */site-packages/* .github/000077500000000000000000000000001416014621300124145ustar00rootroot00000000000000.github/workflows/000077500000000000000000000000001416014621300144515ustar00rootroot00000000000000.github/workflows/main.yaml000066400000000000000000000115171416014621300162660ustar00rootroot00000000000000name: CI on: push: branches: - devel - master - "release-*" pull_request: branches: - devel - master - "release-*" workflow_dispatch: jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: - '3.5' - '3.6' - '3.7' - '3.8' - '3.9' - '3.10' test-type: - e2e e2e-software: - prosody e2e-version: - '0.11' include: # e2e-tests for non-default prosody will be run with the Python # version available in debian stable - python-version: '3.7' test-type: e2e e2e-software: ejabberd e2e-version: '18.09' - python-version: '3.7' test-type: e2e e2e-software: ejabberd e2e-version: '19.08' # plain unit test runs with most recent python version - python-version: '3.9' test-type: unit e2e-software: coveralls e2e-version: latest # no proper allow-failure in GitHub actions, so we have to disable # those instead :( # (see https://github.com/actions/toolkit/issues/399) # - python-version: '3.7' # test-type: e2e # e2e-software: prosody # e2e-version: 'trunk' # - python-version: '3.7' # test-type: e2e # e2e-software: metronome # e2e-version: 'master' # - python-version: '3.7' # test-type: e2e # e2e-software: ejabberd # e2e-version: latest name: '${{ matrix.test-type }}: py${{ matrix.python-version }}, ${{ matrix.e2e-software }}@${{ matrix.e2e-version }}' steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: '${{ matrix.python-version }}' - name: Install Prosody if: matrix.e2e-software == 'prosody' run: | set -euo pipefail export PATH=$PWD/lua_install/bin:$PATH echo deb http://packages.prosody.im/debian $(lsb_release -sc) main | sudo tee -a /etc/apt/sources.list wget https://prosody.im/files/prosody-debian-packages.key -O- | sudo apt-key add - sudo apt-get update printf '#!/bin/sh\nexit 101\n' | sudo tee /usr/sbin/policy-rc.d sudo chmod +x /usr/sbin/policy-rc.d ./utils/install-prosody.sh env: WITH_BUILD_DEP: "yes" PROSODY_BRANCH: "${{ matrix.e2e-version }}" LUA_VERSION: "5.1" - name: Install Metronome if: matrix.e2e-software == 'metronome' run: | set -euo pipefail export PATH=$PWD/lua_install/bin:$PATH # enable source repositories for build dependencies sudo sed -ri 's/^# deb-src/deb-src/' /etc/apt/sources.list /etc/apt/sources.list.d/* sudo apt-get update sudo apt-get install libevent-dev ./utils/install-metronome.sh env: WITH_BUILD_DEP: "yes" METRONOME_VERSION: "${{ matrix.e2e-version }}" - name: Prepare ejabberd if: matrix.e2e-software == 'ejabberd' run: | set -euo pipefail ./utils/prepare-ejabberd.sh env: EJABBERD_VERSION: "${{ matrix.e2e-version }}" - name: Install aioxmpp and test utils run: | set -euo pipefail pip install pytest pytest-cov coveralls pyOpenSSL pip install . - name: Run test suite run: | set -euo pipefail export PATH=$PWD/lua_install/bin:$PATH if [[ "x$TEST_TYPE" = 'xunit' ]]; then pytest --cov aioxmpp --cov-report xml tests else case "$E2E_SOFTWARE" in prosody) export PROSODY_BRANCH="$E2E_VERSION" ./utils/travis-e2etest-prosody.py ;; metronome) export METRONOME_VERSION="$E2E_VERSION" ./utils/travis-e2etest-metronome.py ;; ejabberd) export EJABBERD_VERSION="$E2E_VERSION" ./utils/travis-e2etest-ejabberd.py ;; *) echo "Invalid e2e software: ${E2E_SOFTWARE}" >&2 exit 1 ;; esac fi env: TEST_TYPE: ${{ matrix.test-type }} E2E_SOFTWARE: ${{ matrix.e2e-software }} E2E_VERSION: ${{ matrix.e2e-version }} - name: Coveralls uses: AndreMiras/coveralls-python-action@develop if: matrix.test-type == 'unit' with: parallel: true flag-name: unit finish: needs: test runs-on: ubuntu-latest name: Finalize steps: - name: Finalize Coveralls interaction uses: AndreMiras/coveralls-python-action@develop with: parallel-finished: true .gitignore000066400000000000000000000001071416014621300130420ustar00rootroot00000000000000__pycache__ xmltest.py dist aioxmpp.egg-info .local .vagrant .coverage .mailmap000066400000000000000000000000521416014621300124720ustar00rootroot00000000000000Jonas Schäfer .travis-pinstore.json000066400000000000000000000006341416014621300152010ustar00rootroot00000000000000{"localhost": ["MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6CZwixsiEk5Mzwi2A6mtdHA/UYTcf3drO6FRXPvr7neHJj4jGkXbj8uCMAlHTY70Is/b3x47YFU07QifQtn9VshMdqj2JAK1VFAEtSeGTDwjs8JBjauuiqw5g45iXZTg/TdtwwX62kajlizE4E502yBUsKf8uF/N0HJxuRelB8vqT1jgGZZegIHzhO4vtqqseTy1t5J8nu3gGAxctR3hd2EXszPW/08BknuDHXDkEuIN9eXCg2X9ANNSTDg+EA0Wu0XeCBuMj7rlMqI5Ld3KxLd5VYSeU+PMiyM30KswQsVx3AqYyCSQtGjETFYkozhPNfJznD9vuJYiv6BCCMUw3wIDAQAB"]} COPYING.LESSER000066400000000000000000000167431416014621300131160ustar00rootroot00000000000000 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.gpl3000066400000000000000000001045131416014621300127570ustar00rootroot00000000000000 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 . LICENSES000066400000000000000000000017301416014621300122050ustar00rootroot00000000000000Licenses ======== 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.in000066400000000000000000000001311416014621300126050ustar00rootroot00000000000000include COPYING.gpl3 include COPYING.LESSER include LICENSES include docs/licenses/*.txt Makefile000066400000000000000000000003731416014621300125170ustar00rootroot00000000000000SPHINXBUILD ?= 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.rst000066400000000000000000000132321416014621300125440ustar00rootroot00000000000000``aioxmpp`` ########### .. image:: https://travis-ci.org/horazont/aioxmpp.svg?branch=devel :target: https://travis-ci.org/horazont/aioxmpp .. image:: https://coveralls.io/repos/github/horazont/aioxmpp/badge.svg?branch=devel :target: https://coveralls.io/github/horazont/aioxmpp?branch=devel .. image:: https://img.shields.io/pypi/v/aioxmpp.svg :target: https://pypi.python.org/pypi/aioxmpp/ ... is a pure-python XMPP library using the `asyncio`_ standard library module from Python 3.4 (and `available as a third-party module to Python 3.3`__). .. _asyncio: https://docs.python.org/3/library/asyncio.html __ https://code.google.com/p/tulip/ .. remember to update the feature list in the docs Features ======== * Native `Stream Management (XEP-0198) `_ support for robustness against transient network failures (such as switching between wireless and wired networks). * Powerful declarative-style definition of XEP-based and custom protocols. Most of the time, you will not get in contact with raw XML or character data, even when implementing a new protocol. * Secure by default: TLS is required by default, as well as certificate validation. Certificate or public key pinning can be used, if needed. * Support for `RFC 6121 (Instant Messaging and Presence) `_ roster and presence management, along with `XEP-0045 (Multi-User Chats) `_ for your human-to-human needs. * Support for `XEP-0060 (Publish-Subscribe) `_ and `XEP-0050 (Ad-Hoc Commands) `_ for your machine-to-machine needs. * Several other XEPs, such as `XEP-0115 `_ (including native support for the reading and writing the `capsdb `_) and `XEP-0131 `_. * APIs suitable for both one-shot scripts and long-running multi-account clients. * Well-tested and modular codebase: aioxmpp is developed in test-driven style and in addition to that, many modules are automatedly tested against `Prosody `_ and `ejabberd `_, two popular XMPP servers. There is more and there’s yet more to come! Check out the list of supported XEPs in the `official documentation`_ and `open GitHub issues tagged as enhancement `_ for things which are planned and read on below on how to contribute. Documentation ============= The ``aioxmpp`` API is thoroughly documented using Sphinx. Check out the `official documentation`_ for a `quick start`_ and the `API reference`_. Dependencies ============ * Python ≥ 3.4 (or Python = 3.3 with tulip and enum34) * DNSPython * lxml * `sortedcollections`__ __ https://pypi.python.org/pypi/sortedcollections * `tzlocal`__ (for i18n support) __ https://pypi.python.org/pypi/tzlocal * `pyOpenSSL`__ __ https://pypi.python.org/pypi/pyOpenSSL * `pyasn1`_ and `pyasn1_modules`__ .. _pyasn1: https://pypi.python.org/pypi/pyasn1 __ https://pypi.python.org/pypi/pyasn1-modules * `aiosasl`__ (≥ 0.3 for ``ANONYMOUS`` support) __ https://pypi.python.org/pypi/aiosasl * `multidict`__ __ https://pypi.python.org/pypi/multidict * `aioopenssl`__ __ https://github.com/horazont/aioopenssl * `typing`__ (Python < 3.5 only) __ https://pypi.python.org/pypi/typing Contributing ============ If you consider contributing to aioxmpp, you can do so, even without a GitHub account. There are several ways to get in touch with the aioxmpp developer(s): * `The development mailing list `_. Feel free to subscribe and post, but be polite and adhere to the `Netiquette (RFC 1855) `_. Pull requests posted to the mailing list are also welcome! * The development MUC at ``aioxmpp@conference.zombofant.net``. Pull requests announced in the MUC are also welcome! Note that the MUC is set persistent, but nevertheless there may not always be people around. If in doubt, use the mailing list instead. * Open or comment on an issue or post a pull request on `GitHub `_. No idea what to do, but still want to get your hands dirty? Check out the list of `'help wanted' issues on GitHub `_ or ask in the MUC or on the mailing list. The issues tagged as 'help wanted' are usually of narrow scope, aimed at beginners. Be sure to read the ``docs/CONTRIBUTING.rst`` for some hints on how to author your contribution. Security issues --------------- If you believe that a bug you found in aioxmpp has security implications, you are welcome to notify me privately. To do so, send a mail to `Jonas Schäfer `_, encrypted using the GPG public key 0xE5EDE5AC679E300F (Fingerprint AA5A 78FF 508D 8CF4 F355 F682 E5ED E5AC 679E 300F). If you prefer to disclose security issues immediately, you can do so at any of the places listed above. More details can be found in the `SECURITY.md `_ file. Change log ========== The `change log`_ is included in the `official documentation`_. .. _change log: https://docs.zombofant.net/aioxmpp/0.13/api/changelog.html .. _official documentation: https://docs.zombofant.net/aioxmpp/0.13/ .. _quick start: https://docs.zombofant.net/aioxmpp/0.13/user-guide/quickstart.html .. _API reference: https://docs.zombofant.net/aioxmpp/0.13/api/index.html SECURITY.md000066400000000000000000000024341416014621300126500ustar00rootroot00000000000000# Security Policy ## Supported Versions This is an overview of the security support status of aioxmpp releases. For the supported versions, we provide backports of security relevant patches ASAP after we become aware of them. **Note:** Distributors of aioxmpp may also support older versions. | Version | Supported | | -------- | ------------------ | | 0.10.x | :white_check_mark: | | < 0.10.0 | :x: | ## Reporting a Vulnerability To report a vulnerability, you can send a GPG encrypted message to the main maintainer of aioxmpp, [Jonas Schäfer](mailto:jonas@wielicki.name). The GPG key ID is 0xE5EDE5AC679E300F (full fingerprint: AA5A 78FF 508D 8CF4 F355 F682 E5ED E5AC 679E 300F). If you prefer to report vulnerabilities publicly right away, you can do so like you would report normal issues; that is, here on GitHub, in our chat room or on the mailing list (see the README for details). When you report a security vulnerability, we will handle it with the highest priority and work, possibly with you, to create a fix. Please provide as much information as possible in the initial report so that we can get to the core of the issue right away. We will keep you posted on progress of fixing the issue via a communication channel we negotiate when you first report it. aioxmpp/000077500000000000000000000000001416014621300125315ustar00rootroot00000000000000aioxmpp/__init__.py000066400000000000000000000106631416014621300146500ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ Version information ################### There are two ways to obtain the imported version of the :mod:`aioxmpp` package: .. autodata:: __version__ .. data:: version Alias of :data:`__version__`. .. autodata:: version_info .. _api-aioxmpp-services: Overview of Services #################### .. autosummary:: :nosignatures: aioxmpp.AdHocClient aioxmpp.AvatarService aioxmpp.BlockingClient aioxmpp.BookmarkClient aioxmpp.CarbonsClient aioxmpp.DiscoClient aioxmpp.DiscoServer aioxmpp.EntityCapsService aioxmpp.MUCClient aioxmpp.PingService aioxmpp.PresenceClient aioxmpp.PresenceServer aioxmpp.PEPClient aioxmpp.RosterClient aioxmpp.VersionServer Shorthands ########## .. function:: make_security_layer Alias of :func:`aioxmpp.security_layer.make`. """ from ._version import version_info, __version__, version # NOQA: F401 #: The imported :mod:`aioxmpp` version as a tuple. #: #: The components of the tuple are, in order: `major version`, `minor version`, #: `patch level`, and `pre-release identifier`. #: #: .. seealso:: #: #: :ref:`api-stability` version_info = version_info #: The imported :mod:`aioxmpp` version as a string. #: #: The version number is dot-separated; in pre-release or development versions, #: the version number is followed by a hypen-separated pre-release identifier. #: #: .. seealso:: #: #: :ref:`api-stability` __version__ = __version__ # XXX: ^ this is a hack to make Sphinx find the docs. We could also be using # .. data instead of .. autodata, but that has the downside that the actual # version number isn’t printed in the docs (without additional maintenance # cost). import asyncio # NOQA # Adds fallback if asyncio version does not provide an ensure_future function. if not hasattr(asyncio, "ensure_future"): asyncio.ensure_future = getattr(asyncio, "async") from .errors import ( # NOQA XMPPAuthError, XMPPCancelError, XMPPContinueError, XMPPModifyError, XMPPWaitError, ErrorCondition, ) from .stanza import Presence, IQ, Message # NOQA: F401 from .structs import ( # NOQA: F401 JID, PresenceShow, PresenceState, MessageType, PresenceType, IQType, ErrorType, jid_escape, jid_unescape, ) from .security_layer import make as make_security_layer # NOQA: F401 from .node import Client, PresenceManagedClient # NOQA: F401 # services from .presence import PresenceClient, PresenceServer # NOQA: F401 from .roster import RosterClient # NOQA: F401 from .disco import DiscoServer, DiscoClient # NOQA: F401 from .entitycaps import EntityCapsService # NOQA: F401 from .muc import MUCClient # NOQA: F401 from .pubsub import PubSubClient # NOQA: F401 from .shim import SHIMService # NOQA: F401 from .adhoc import AdHocClient, AdHocServer # NOQA: F401 from .avatar import AvatarService # NOQA: F401 from .blocking import BlockingClient # NOQA: F401 from .carbons import CarbonsClient # NOQA: F401 from .ping import PingService # NOQA: F401 from .pep import PEPClient # NOQA: F401 from .bookmarks import BookmarkClient # NOQA: F401 from .version import VersionServer # NOQA: F401 from .mdr import DeliveryReceiptsService # NOQA: F401 from . import httpupload # NOQA: F401 def set_strict_mode(): from .stanza import Error from .stream import StanzaStream from . import structs Message.type_.type_.allow_coerce = False IQ.type_.type_.allow_coerce = False Error.type_.type_.allow_coerce = False Presence.type_.type_.allow_coerce = False StanzaStream._ALLOW_ENUM_COERCION = False structs._USE_COMPAT_ENUM = False aioxmpp/_version.py000066400000000000000000000021011416014621300147210ustar00rootroot00000000000000######################################################################## # File name: _version.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## version_info = (0, 13, 1, None) __version__ = ".".join(map(str, version_info[:3])) + ("-"+version_info[3] if version_info[3] else "") version = __version__ aioxmpp/adhoc/000077500000000000000000000000001416014621300136075ustar00rootroot00000000000000aioxmpp/adhoc/__init__.py000066400000000000000000000036201416014621300157210ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.adhoc` --- Ad-Hoc Commands support (:xep:`50`) ############################################################# This subpackage implements support for Ad-Hoc Commands as specified in :xep:`50`. Both the client and the server side of Ad-Hoc Commands are supported. .. versionadded:: 0.8 Client-side =========== .. currentmodule:: aioxmpp .. autoclass:: AdHocClient .. currentmodule:: aioxmpp.adhoc.service .. autoclass:: ClientSession Server-side =========== .. currentmodule:: aioxmpp.adhoc .. autoclass:: AdHocServer .. currentmodule:: aioxmpp.adhoc.service .. .. autoclass:: ServerSession XSOs ==== .. currentmodule:: aioxmpp.adhoc.xso .. autoclass:: Command .. autoclass:: Actions .. autoclass:: Note .. currentmodule:: aioxmpp.adhoc Enumerations ------------ .. autoclass:: CommandStatus .. autoclass:: ActionType """ from .service import ( # NOQA: F401 AdHocClient, ClientSession, AdHocServer, ) from .xso import ( # NOQA: F401 CommandStatus, ActionType, ) from . import xso # NOQA: F401 aioxmpp/adhoc/service.py000066400000000000000000000453251416014621300156320ustar00rootroot00000000000000######################################################################## # File name: service.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio # import base64 import collections import logging import random # from datetime import timedelta import aioxmpp.disco import aioxmpp.errors import aioxmpp.disco.xso as disco_xso import aioxmpp.service import aioxmpp.structs from aioxmpp.utils import namespaces from . import xso as adhoc_xso _logger = logging.getLogger(__name__) _rng = random.SystemRandom() class SessionError(RuntimeError): pass class ClientCancelledError(SessionError): pass class AdHocClient(aioxmpp.service.Service): """ Access other entities :xep:`50` Ad-Hoc commands. This service provides helpers to conveniently access and execute :xep:`50` Ad-Hoc commands. .. automethod:: supports_commands .. automethod:: get_commands .. automethod:: get_command_info .. automethod:: execute """ ORDER_AFTER = [aioxmpp.disco.DiscoClient] async def get_commands(self, peer_jid): """ Return the list of commands offered by the peer. :param peer_jid: JID of the peer to query :type peer_jid: :class:`~aioxmpp.JID` :rtype: :class:`list` of :class:`~.disco.xso.Item` :return: List of command items In the returned list, each :class:`~.disco.xso.Item` represents one command supported by the peer. The :attr:`~.disco.xso.Item.node` attribute is the identifier of the command which can be used with :meth:`get_command_info` and :meth:`execute`. """ disco = self.dependencies[aioxmpp.disco.DiscoClient] response = await disco.query_items( peer_jid, node=namespaces.xep0050_commands, ) return response.items async def get_command_info(self, peer_jid, command_name): """ Obtain information about a command. :param peer_jid: JID of the peer to query :type peer_jid: :class:`~aioxmpp.JID` :param command_name: Node name of the command :type command_name: :class:`str` :rtype: :class:`~.disco.xso.InfoQuery` :return: Service discovery information about the command Sends a service discovery query to the service discovery node of the command. The returned object contains information about the command, such as the namespaces used by its implementation (generally the :xep:`4` data forms namespace) and possibly localisations of the commands name. The `command_name` can be obtained by inspecting the listing from :meth:`get_commands` or from well-known command names as defined for example in :xep:`133`. """ disco = self.dependencies[aioxmpp.disco.DiscoClient] response = await disco.query_info( peer_jid, node=command_name, ) return response async def supports_commands(self, peer_jid): """ Detect whether a peer supports :xep:`50` Ad-Hoc commands. :param peer_jid: JID of the peer to query :type peer_jid: :class:`aioxmpp.JID` :rtype: :class:`bool` :return: True if the peer supports the Ad-Hoc commands protocol, false otherwise. Note that the fact that a peer supports the protocol does not imply that it offers any commands. """ disco = self.dependencies[aioxmpp.disco.DiscoClient] response = await disco.query_info( peer_jid, ) return namespaces.xep0050_commands in response.features async def execute(self, peer_jid, command_name): """ Start execution of a command with a peer. :param peer_jid: JID of the peer to start the command at. :type peer_jid: :class:`~aioxmpp.JID` :param command_name: Node name of the command to execute. :type command_name: :class:`str` :rtype: :class:`~.adhoc.service.ClientSession` :return: A started command execution session. Initialises a client session and starts execution of the command. The session is returned. This may raise any exception which may be raised by :meth:`~.adhoc.service.ClientSession.start`. """ session = ClientSession( self.client.stream, peer_jid, command_name, ) await session.start() return session CommandEntry = collections.namedtuple( "CommandEntry", [ "name", "is_allowed", "handler", "features", ] ) class CommandEntry(aioxmpp.disco.StaticNode): def __init__(self, name, handler, features=set(), is_allowed=None): super().__init__() if isinstance(name, str): self.__name = aioxmpp.structs.LanguageMap({None: name}) else: self.__name = aioxmpp.structs.LanguageMap(name) self.__handler = handler features = set(features) | {namespaces.xep0050_commands} for feature in features: self.register_feature(feature) self.__is_allowed = is_allowed self.register_identity( "automation", "command-node", names=self.__name ) @property def name(self): return self.__name @property def handler(self): return self.__handler @property def is_allowed(self): return self.__is_allowed def is_allowed_for(self, *args, **kwargs): if self.__is_allowed is None: return True return self.__is_allowed(*args, **kwargs) def iter_identities(self, stanza): if not self.is_allowed_for(stanza.from_): return iter([]) return super().iter_identities(stanza) class AdHocServer(aioxmpp.service.Service, aioxmpp.disco.Node): """ Support for serving Ad-Hoc commands. .. .. automethod:: register_stateful_command .. automethod:: register_stateless_command .. automethod:: unregister_command """ ORDER_AFTER = [aioxmpp.disco.DiscoServer] disco_node = aioxmpp.disco.mount_as_node( "http://jabber.org/protocol/commands" ) disco_feature = aioxmpp.disco.register_feature( "http://jabber.org/protocol/commands" ) def __init__(self, client, **kwargs): super().__init__(client, **kwargs) self.register_identity( "automation", "command-list", ) self._commands = {} self._disco = self.dependencies[aioxmpp.disco.DiscoServer] @aioxmpp.service.iq_handler(aioxmpp.IQType.SET, adhoc_xso.Command) async def _handle_command(self, stanza): try: info = self._commands[stanza.payload.node] except KeyError: raise aioxmpp.errors.XMPPCancelError( aioxmpp.errors.ErrorCondition.ITEM_NOT_FOUND, text="no such command: {!r}".format( stanza.payload.node ) ) if not info.is_allowed_for(stanza.from_): raise aioxmpp.errors.XMPPCancelError( aioxmpp.errors.ErrorCondition.FORBIDDEN, ) return await info.handler(stanza) def iter_items(self, stanza): local_jid = self.client.local_jid languages = [ aioxmpp.structs.LanguageRange.fromstr("en"), ] if stanza.lang is not None: languages.insert(0, aioxmpp.structs.LanguageRange.fromstr( str(stanza.lang) )) for node, info in self._commands.items(): if not info.is_allowed_for(stanza.from_): continue yield disco_xso.Item( local_jid, name=info.name.lookup(languages), node=node, ) def register_stateless_command(self, node, name, handler, *, is_allowed=None, features={namespaces.xep0004_data}): """ Register a handler for a stateless command. :param node: Name of the command (``node`` in the service discovery list). :type node: :class:`str` :param name: Human-readable name of the command :type name: :class:`str` or :class:`~.LanguageMap` :param handler: Coroutine function to run to get the response for a request. :param is_allowed: A predicate which determines whether the command is shown and allowed for a given peer. :type is_allowed: function or :data:`None` :param features: Set of features to announce for the command :type features: :class:`set` of :class:`str` When a request for the command is received, `handler` is invoked. The semantics of `handler` are the same as for :meth:`~.StanzaStream.register_iq_request_handler`. It must produce a valid :class:`~.adhoc.xso.Command` response payload. If `is_allowed` is not :data:`None`, it is invoked whenever a command listing is generated and whenever a command request is received. The :class:`aioxmpp.JID` of the requester is passed as positional argument to `is_allowed`. If `is_allowed` returns false, the command is not included in the list and attempts to execute it are rejected with ```` without calling `handler`. If `is_allowed` is :data:`None`, the command is always visible and allowed. The `features` are returned on a service discovery info request for the command node. By default, the :xep:`4` (Data Forms) namespace is included, but this can be overridden by passing a different set without that feature to `features`. """ info = CommandEntry( name, handler, is_allowed=is_allowed, features=features, ) self._commands[node] = info self._disco.mount_node( node, info, ) def unregister_command(self, node): """ Unregister a command previously registered. :param node: Name of the command (``node`` in the service discovery list). :type node: :class:`str` """ class ClientSession: """ Represent an Ad-Hoc command session on the client side. :param stream: The stanza stream over which the session is established. :type stream: :class:`~.StanzaStream` :param peer_jid: The full JID of the peer to communicate with :type peer_jid: :class:`~aioxmpp.JID` :param command_name: The command to run :type command_name: :class:`str` The constructor does not send any stanza, it merely prepares the internal state. To start the command itself, use the :class:`ClientSession` object as context manager or call :meth:`start`. .. note:: The client session returned by :meth:`.AdHocClient.execute` is already started. The `command_name` must be one of the :attr:`~.disco.xso.Item.node` values as returned by :meth:`.AdHocClient.get_commands`. .. automethod:: start .. automethod:: proceed .. automethod:: close The following attributes change depending on the stage of execution of the command: .. autoattribute:: allowed_actions .. autoattribute:: first_payload .. autoattribute:: response .. autoattribute:: status """ def __init__(self, stream, peer_jid, command_name, *, logger=None): super().__init__() self._stream = stream self._peer_jid = peer_jid self._command_name = command_name self._logger = logger or _logger self._status = None self._response = None @property def status(self): """ The current status of command execution. This is either :data:`None` or one of the :class:`~.adhoc.CommandStatus` enumeration values. Initially, this attribute is :data:`None`. After calls to :meth:`start`, :meth:`proceed` or :meth:`close`, it takes the value of the :attr:`~.xso.Command.status` attribute of the response. """ if self._response is not None: return self._response.status return None @property def response(self): """ The last :class:`~.xso.Command` received from the peer. This is initially (and after :meth:`close`) :data:`None`. """ return self._response @property def first_payload(self): """ Shorthand to access :attr:`~.xso.Command.first_payload` of the :attr:`response`. This is initially (and after :meth:`close`) :data:`None`. """ if self._response is not None: return self._response.first_payload return None @property def sessionid(self): """ Shorthand to access :attr:`~.xso.Command.sessionid` of the :attr:`response`. This is initially (and after :meth:`close`) :data:`None`. """ if self._response is not None: return self._response.sessionid return None @property def allowed_actions(self): """ Shorthand to access :attr:`~.xso.Actions.allowed_actions` of the :attr:`response`. If no response has been received yet or if the response specifies no set of valid actions, this is the minimal set of allowed actions ( :attr:`~.ActionType.EXECUTE` and :attr:`~.ActionType.CANCEL`). """ if self._response is not None and self._response.actions is not None: return self._response.actions.allowed_actions return {adhoc_xso.ActionType.EXECUTE, adhoc_xso.ActionType.CANCEL} async def start(self): """ Initiate the session by starting to execute the command with the peer. :return: The :attr:`~.xso.Command.first_payload` of the response This sends an empty command IQ request with the :attr:`~.ActionType.EXECUTE` action. The :attr:`status`, :attr:`response` and related attributes get updated with the newly received values. """ if self._response is not None: raise RuntimeError("command execution already started") request = aioxmpp.IQ( type_=aioxmpp.IQType.SET, to=self._peer_jid, payload=adhoc_xso.Command(self._command_name), ) self._response = await self._stream.send_iq_and_wait_for_reply( request, ) return self._response.first_payload async def proceed(self, *, action=adhoc_xso.ActionType.EXECUTE, payload=None): """ Proceed command execution to the next stage. :param action: Action type for proceeding :type action: :class:`~.ActionTyp` :param payload: Payload for the request, or :data:`None` :return: The :attr:`~.xso.Command.first_payload` of the response `action` must be one of the actions returned by :attr:`allowed_actions`. It defaults to :attr:`~.ActionType.EXECUTE`, which is (alongside with :attr:`~.ActionType.CANCEL`) always allowed. `payload` may be a sequence of XSOs, a single XSO or :data:`None`. If it is :data:`None`, the XSOs from the request are re-used. This is useful if you modify the payload in-place (e.g. via :attr:`first_payload`). Otherwise, the payload on the request is set to the `payload` argument; if it is a single XSO, it is wrapped in a sequence. The :attr:`status`, :attr:`response` and related attributes get updated with the newly received values. """ if self._response is None: raise RuntimeError("command execution not started yet") if action not in self.allowed_actions: raise ValueError("action {} not allowed in this stage".format( action )) cmd = adhoc_xso.Command( self._command_name, action=action, payload=self._response.payload if payload is None else payload, sessionid=self.sessionid, ) request = aioxmpp.IQ( type_=aioxmpp.IQType.SET, to=self._peer_jid, payload=cmd, ) try: self._response = await self._stream.send_iq_and_wait_for_reply( request, ) except (aioxmpp.errors.XMPPModifyError, aioxmpp.errors.XMPPCancelError) as exc: if isinstance(exc.application_defined_condition, (adhoc_xso.BadSessionID, adhoc_xso.SessionExpired)): await self.close() raise SessionError(exc.text) if isinstance(exc, aioxmpp.errors.XMPPCancelError): await self.close() raise return self._response.first_payload async def close(self): if self._response is None: return if self.status != adhoc_xso.CommandStatus.COMPLETED: request = aioxmpp.IQ( type_=aioxmpp.IQType.SET, to=self._peer_jid, payload=adhoc_xso.Command( self._command_name, sessionid=self.sessionid, action=adhoc_xso.ActionType.CANCEL, ) ) try: await self._stream.send_iq_and_wait_for_reply( request, ) except aioxmpp.errors.StanzaError as exc: # we are cancelling only out of courtesy. # if something goes wrong here, it’s barely worth logging self._logger.debug( "ignored stanza error during close(): %r", exc, ) self._response = None async def __aenter__(self): if self._response is None: await self.start() return self async def __aexit__(self, exc_type, exc_value, exc_traceback): await self.close() aioxmpp/adhoc/xso.py000066400000000000000000000130411416014621300147710ustar00rootroot00000000000000######################################################################## # 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/000077500000000000000000000000001416014621300140075ustar00rootroot00000000000000aioxmpp/avatar/__init__.py000066400000000000000000000053541416014621300161270ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.avatar` --- User avatar support (:xep:`0084`) ############################################################ This module provides support for publishing and retrieving user avatars as per :xep:`User Avatar <84>`. Services ======== The following service is provided by this subpackage: .. currentmodule:: aioxmpp .. autosummary:: AvatarService The detailed documentation of the classes follows: .. autoclass:: AvatarService() .. currentmodule:: aioxmpp.avatar Data Representation =================== The following class is used to describe the possible locations of an avatar image: .. autoclass:: AvatarSet .. module:: aioxmpp.avatar.service .. currentmodule:: aioxmpp.avatar.service .. autoclass:: AbstractAvatarDescriptor() .. currentmodule:: aioxmpp.avatar Helpers ======= .. autofunction:: normalize_id How to work with avatar descriptors =================================== .. currentmodule:: aioxmpp.avatar.service One you have retrieved the avatar descriptor list, the correct way to handle it in the application: 1. Select the avatar you prefer based on the :attr:`~AbstractAvatarDescriptor.can_get_image_bytes_via_xmpp`, and metadata information (:attr:`~AbstractAvatarDescriptor.mime_type`, :attr:`~AbstractAvatarDescriptor.width`, :attr:`~AbstractAvatarDescriptor.height`, :attr:`~AbstractAvatarDescriptor.nbytes`). If you cache avatar images it might be a good choice to choose an avatar image you already have cached based on :attr:`~AbstractAvatarDescriptor.normalized_id`. 2. If :attr:`~AbstractAvatarDescriptor.can_get_image_bytes_via_xmpp` is true, try to retrieve the image by :attr:`~AbstractAvatarDescriptor.get_image_bytes()`; if it is false try to retrieve the object at the URL :attr:`~AbstractAvatarDescriptor.url`. """ from .service import (AvatarSet, AvatarService, # NOQA: F401 normalize_id) aioxmpp/avatar/service.py000066400000000000000000001027331416014621300160270ustar00rootroot00000000000000######################################################################## # File name: service.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio import hashlib import logging import warnings import aioxmpp import aioxmpp.callbacks as callbacks import aioxmpp.service as service import aioxmpp.disco as disco import aioxmpp.pep as pep import aioxmpp.presence as presence import aioxmpp.pubsub as pubsub import aioxmpp.vcard as vcard from aioxmpp.cache import LRUDict from aioxmpp.utils import namespaces, gather_reraise_multi from . import xso as avatar_xso logger = logging.getLogger(__name__) def normalize_id(id_): """ Normalize a SHA1 sum encoded as hexadecimal number in ASCII. This does nothing but lowercase the string as to enable robust comparison. """ return id_.lower() class AvatarSet: """ A list of sources of an avatar. Exactly one of the sources must include image data in the ``image/png`` format. The others provide the location of the image data as an URL. Adding pointer avatar data is not yet supported. .. automethod:: add_avatar_image """ def __init__(self): self._image_bytes = None self._png_id = None self._metadata = avatar_xso.Metadata() @property def image_bytes(self): """ The image data bytes for MIME type ``text/png``. """ return self._image_bytes @property def metadata(self): """ The :class:`Metadata` XSO corresponding to this avatar set. """ return self._metadata @property def png_id(self): """ The SHA1 of the ``image/png`` image data. This id is always normalized in the sense of :function:`normalize_id`. """ return self._png_id def add_avatar_image(self, mime_type, *, id_=None, image_bytes=None, width=None, height=None, url=None, nbytes=None): """ Add a source of the avatar image. All sources of an avatar image added to an avatar set must be *the same image*, in different formats and sizes. :param mime_type: The MIME type of the avatar image. :param id_: The SHA1 of the image data. :param nbytes: The size of the image data in bytes. :param image_bytes: The image data, this must be supplied only in one call. :param url: The URL of the avatar image. :param height: The height of the image in pixels (optional). :param width: The width of the image in pixels (optional). `id_` and `nbytes` may be omitted if and only if `image_data` is given and `mime_type` is ``image/png``. If they are supplied *and* image data is given, they are checked to match the image data. It is the caller's responsibility to assure that the provided links exist and the files have the correct SHA1 sums. """ if mime_type == "image/png": if image_bytes is not None: if self._image_bytes is not None: raise RuntimeError( "Only one avatar image may be published directly." ) sha1 = hashlib.sha1() sha1.update(image_bytes) id_computed = normalize_id(sha1.hexdigest()) if id_ is not None: id_ = normalize_id(id_) if id_ != id_computed: raise RuntimeError( "The given id does not match the SHA1 of " "the image data." ) else: id_ = id_computed nbytes_computed = len(image_bytes) if nbytes is not None: if nbytes != nbytes_computed: raise RuntimeError( "The given length does not match the length " "of the image data." ) else: nbytes = nbytes_computed self._image_bytes = image_bytes self._png_id = id_ if image_bytes is None and url is None: raise RuntimeError( "Either the image bytes or an url to retrieve the avatar " "image must be given." ) if nbytes is None: raise RuntimeError( "Image data length is not given an not inferable " "from the other arguments." ) if id_ is None: raise RuntimeError( "The SHA1 of the image data is not given an not inferable " "from the other arguments." ) if image_bytes is not None and mime_type != "image/png": raise RuntimeError( "The image bytes can only be given for image/png data." ) self._metadata.info[mime_type].append( avatar_xso.Info( id_=id_, mime_type=mime_type, nbytes=nbytes, width=width, height=height, url=url ) ) class AbstractAvatarDescriptor: """ Description of the properties of and how to retrieve a specific avatar. The following attributes are available for all instances: .. autoattribute:: remote_jid .. autoattribute:: id_ .. autoattribute:: normalized_id .. autoattribute:: can_get_image_bytes_via_xmpp .. autoattribute:: has_image_data_in_pubsub The following attributes may be :data:`None` and are supposed to be used as hints for selection of the avatar to download: .. autoattribute:: nbytes .. autoattribute:: width .. autoattribute:: height .. autoattribute:: mime_type If this attribute is not :data:`None` it is an URL that points to the location of the avatar image: .. autoattribute:: url The image data belonging to the descriptor can be retrieved by the following coroutine: .. automethod:: get_image_bytes """ def __init__(self, remote_jid, id_, *, mime_type=None, nbytes=None, width=None, height=None, url=None): self._remote_jid = remote_jid self._mime_type = mime_type self._id = id_ self._nbytes = nbytes self._width = width self._height = height self._url = url def __eq__(self, other): return (self._remote_jid == other._remote_jid and self._mime_type == other._mime_type and self._id == other._id and self._nbytes == other._nbytes and self._width == other._width and self._height == other._height and self._url == other._url) async def get_image_bytes(self): """ Try to retrieve the image data corresponding to this avatar descriptor. :returns: the image contents :rtype: :class:`bytes` :raises NotImplementedError: if we do not implement the capability to retrieve the image data of this type. It is guaranteed to not raise :class:`NotImplementedError` if :attr:`can_get_image_bytes_via_xmpp` is true. :raises RuntimeError: if the image data described by this descriptor is not at the specified location. :raises aiomxpp.XMPPCancelError: if trying to retrieve the image data causes an XMPP error. """ raise NotImplementedError @property def can_get_image_bytes_via_xmpp(self): """ Return whether :meth:`get_image_bytes` raises :class:`NotImplementedError`. """ return False @property def has_image_data_in_pubsub(self): """ Whether the image can be retrieved from PubSub. .. deprecated:: 0.10 Use :attr:`can_get_image_bytes_via_xmpp` instead. As we support vCard based avatars now the name of this is misleading. This attribute will be removed in aioxmpp 1.0 """ warnings.warn( "the has_image_data_in_pubsub attribute is deprecated and will be" " removed in 1.0", DeprecationWarning, stacklevel=1 ) return self.can_get_image_bytes_via_xmpp @property def remote_jid(self): """ The remote JID this avatar belongs to. """ return self._remote_jid @property def url(self): """ The URL where the avatar image data can be found. This may be :data:`None` if the avatar is not given as an URL of the image data. """ return self._url @property def width(self): """ The width of the avatar image in pixels. This is :data:`None` if this information is not supplied. """ return self._width @property def height(self): """ The height of the avatar image in pixels. This is :data:`None` if this information is not supplied. """ return self._height @property def nbytes(self): """ The size of the avatar image data in bytes. """ return self._nbytes @property def id_(self): """ The SHA1 of the image encoded as hexadecimal number in ASCII. This is the original value returned from the underlying protocol and should be used for any further interaction with the underlying protocol. """ return self._id @property def normalized_id(self): """ The normalized SHA1 of the image data. This is supposed to be used for caching and comparison. """ return normalize_id(self._id) @property def mime_type(self): """ The MIME type of the image data. """ return self._mime_type class PubsubAvatarDescriptor(AbstractAvatarDescriptor): def __init__(self, remote_jid, id_, *, pubsub=None, **kwargs): super().__init__(remote_jid, id_, **kwargs) self._pubsub = pubsub def __eq__(self, other): return (isinstance(other, PubsubAvatarDescriptor) and super().__eq__(other)) @property def can_get_image_bytes_via_xmpp(self): return True async def get_image_bytes(self): image_data = await self._pubsub.get_items_by_id( self._remote_jid, namespaces.xep0084_data, [self.id_], ) if not image_data.payload.items: raise RuntimeError("Avatar image data is not set.") item, = image_data.payload.items return item.registered_payload.data class HttpAvatarDescriptor(AbstractAvatarDescriptor): async def get_image_bytes(self): raise NotImplementedError def __eq__(self, other): return (isinstance(other, HttpAvatarDescriptor) and super().__eq__(other)) class VCardAvatarDescriptor(AbstractAvatarDescriptor): def __init__(self, remote_jid, id_, *, vcard=None, image_bytes=None, **kwargs): super().__init__(remote_jid, id_, **kwargs) self._vcard = vcard self._image_bytes = image_bytes def __eq__(self, other): # NOTE: we explicitly do *not* check for the equality of # image bytes: image bytes is a hidden optimization return (isinstance(other, VCardAvatarDescriptor) and super().__eq__(other)) @property def can_get_image_bytes_via_xmpp(self): return True async def get_image_bytes(self): if self._image_bytes is not None: return self._image_bytes logger.debug("retrieving vCard %s", self._remote_jid) vcard = await self._vcard.get_vcard(self._remote_jid) photo = vcard.get_photo_data() if photo is None: raise RuntimeError("Avatar image is not set") logger.debug("returning vCard avatar %s", self._remote_jid) return photo class AvatarService(service.Service): """ Access and publish User Avatars (:xep:`84`). Fallback to vCard based avatars (:xep:`153`) if no PEP avatar is available. This service provides an interface for accessing the avatar of other entities in the network, getting notifications on avatar changes and publishing an avatar for this entity. .. versionchanged:: 0.10 Support for :xep:`vCard-Based Avatars <153>` was added. Observing avatars: .. note:: :class:`AvatarService` only caches the metadata, not the actual image data. This is the job of the caller. .. signal:: on_metadata_changed(jid, metadata) Fires when avatar metadata changes. :param jid: The JID which the avatar belongs to. :param metadata: The new metadata descriptors. :type metadata: a sequence of :class:`~aioxmpp.avatar.service.AbstractAvatarDescriptor` instances .. automethod:: get_avatar_metadata .. automethod:: subscribe Publishing avatars: .. automethod:: publish_avatar_set .. automethod:: disable_avatar .. automethod:: wipe_avatar Configuration: .. autoattribute:: synchronize_vcard .. autoattribute:: advertise_vcard .. attribute:: avatar_pep The PEP descriptor for claiming the avatar metadata namespace. The value is a :class:`~aioxmpp.pep.service.RegisteredPEPNode`, whose :attr:`~aioxmpp.pep.service.RegisteredPEPNode.notify` property can be used to disable or enable the notification feature. .. autoattribute:: metadata_cache_size :annotation: = 200 """ ORDER_AFTER = [ disco.DiscoClient, disco.DiscoServer, pubsub.PubSubClient, pep.PEPClient, vcard.VCardService, presence.PresenceClient, presence.PresenceServer, ] avatar_pep = pep.register_pep_node( namespaces.xep0084_metadata, notify=True, ) on_metadata_changed = callbacks.Signal() def __init__(self, client, **kwargs): super().__init__(client, **kwargs) self._has_pep_avatar = set() self._metadata_cache = LRUDict() self._metadata_cache.maxsize = 200 self._pubsub = self.dependencies[pubsub.PubSubClient] self._pep = self.dependencies[pep.PEPClient] self._presence_server = self.dependencies[presence.PresenceServer] self._disco = self.dependencies[disco.DiscoClient] self._vcard = self.dependencies[vcard.VCardService] # we use this lock to prevent race conditions between different # calls of the methods by one client. # XXX: Other, independent clients may still cause inconsistent # data by race conditions, this should be fixed by at least # checking for consistent data after an update. self._publish_lock = asyncio.Lock() self._synchronize_vcard = False self._advertise_vcard = True self._vcard_resource_interference = set() self._vcard_id = None self._vcard_rehashing_for = None self._vcard_rehash_task = None @property def metadata_cache_size(self): """ Maximum number of cache entries in the avatar metadata cache. This is mostly a measure to prevent malicious peers from exhausting memory by spamming vCard based avatar metadata for different resources. .. versionadded:: 0.10 """ return self._metadata_cache.maxsize @metadata_cache_size.setter def metadata_cache_size(self, value): self._metadata_cache.maxsize = value @property def synchronize_vcard(self): """ Set this property to true to enable publishing the a vCard avatar. This property defaults to false. For the setting true to have effect, you have to publish your avatar with :meth:`publish_avatar_set` or :meth:`disable_avatar` *after* this switch has been set to true. """ return self._synchronize_vcard @synchronize_vcard.setter def synchronize_vcard(self, value): self._synchronize_vcard = bool(value) @property def advertise_vcard(self): """ Set this property to false to disable advertisement of the vCard avatar via presence broadcast. Note, that this reduces traffic, since it makes the presence stanzas smaller and we no longer have to recalculate the hash, this also disables vCard advertisement for all other resources of the bare local jid, by the business rules of :xep:`0153`. Note that, when enabling this feature again the vCard has to be fetched from the server to recalculate the hash. """ return self._advertise_vcard @advertise_vcard.setter def advertise_vcard(self, value): self._advertise_vcard = bool(value) if self._advertise_vcard: self._vcard_id = None self._start_rehash_task() @service.depfilter(aioxmpp.stream.StanzaStream, "service_outbound_presence_filter") def _attach_vcard_notify_to_presence(self, stanza): if self._advertise_vcard: if self._vcard_resource_interference: # do not advertise the hash if there is resource interference stanza.xep0153_x = avatar_xso.VCardTempUpdate() else: stanza.xep0153_x = avatar_xso.VCardTempUpdate(self._vcard_id) return stanza def _update_metadata(self, cache_jid, metadata): try: cached_metadata = self._metadata_cache[cache_jid] except KeyError: pass else: if cached_metadata == metadata: return self._metadata_cache[cache_jid] = metadata self.on_metadata_changed( cache_jid, metadata ) def _handle_notify(self, full_jid, stanza): # handle resource interference as per XEP-153 business rules, # we go along with this tracking even if vcard advertisement # is off if (full_jid.bare() == self.client.local_jid.bare() and full_jid != self.client.local_jid): if stanza.xep0153_x is None: self._vcard_resource_interference.add(full_jid) else: if self._vcard_resource_interference: self._vcard_resource_interference.discard(full_jid) if not self._vcard_resource_interference: self._vcard_id = None # otherwise ignore stanzas without xep0153_x payload, or # no photo tag. if stanza.xep0153_x is None: return if stanza.xep0153_x.photo is None: return # special case MUC presence – otherwise the vcard is retrieved # for the bare jid if stanza.xep0045_muc_user is not None: cache_jid = full_jid else: cache_jid = full_jid.bare() if cache_jid not in self._has_pep_avatar: metadata = self._cook_vcard_notify(cache_jid, stanza) self._update_metadata(cache_jid, metadata) # trigger the download of the vCard and calculation of the # vCard avatar hash, if some other resource of our bare jid # reported a hash distinct from ours! # don't do this if there is a non-compliant resource, we don't # send the hash in that case anyway if (full_jid.bare() == self.client.local_jid.bare() and full_jid != self.client.local_jid and self._advertise_vcard and not self._vcard_resource_interference): if (self._vcard_id is None or stanza.xep0153_x.photo.lower() != self._vcard_id.lower()): # do not rehash if we already have a rehash task that # was triggered by an update with the same hash if (self._vcard_rehashing_for is None or self._vcard_rehashing_for != stanza.xep0153_x.photo.lower()): self._vcard_rehashing_for = stanza.xep0153_x.photo.lower() self._start_rehash_task() def _start_rehash_task(self): if self._vcard_rehash_task is not None: self._vcard_rehash_task.cancel() self._vcard_id = None # as per XEP immediately resend the presence with empty update # element, as this is not synchronous it might already contaiin # the new hash, but this is okay as well (as it makes the cached # presence stanzas coherent as well). self._presence_server.resend_presence() self._vcard_rehash_task = asyncio.ensure_future( self._calculate_vcard_id() ) def set_new_vcard_id(fut): self._vcard_rehashing_for = None if not fut.cancelled(): self._vcard_id = fut.result() self._vcard_rehash_task.add_done_callback( set_new_vcard_id ) async def _calculate_vcard_id(self): self.logger.debug("updating vcard hash") vcard = await self._vcard.get_vcard() self.logger.debug("got vcard for hash update: %s", vcard) photo = vcard.get_photo_data() # if no photo is set in the vcard, set an empty element # in the update; according to the spec this means the avatar # is disabled if photo is None: self.logger.debug("no photo in vcard, advertising as such") return "" sha1 = hashlib.sha1() sha1.update(photo) new_hash = sha1.hexdigest().lower() self.logger.debug("updated hash to %s", new_hash) return new_hash @service.depsignal(presence.PresenceClient, "on_available") def _handle_on_available(self, full_jid, stanza): self._handle_notify(full_jid, stanza) @service.depsignal(presence.PresenceClient, "on_changed") def _handle_on_changed(self, full_jid, stanza): self._handle_notify(full_jid, stanza) @service.depsignal(presence.PresenceClient, "on_unavailable") def _handle_on_unavailable(self, full_jid, stanza): if full_jid.bare() == self.client.local_jid.bare(): if self._vcard_resource_interference: self._vcard_resource_interference.discard(full_jid) if not self._vcard_resource_interference: self._start_rehash_task() # correctly handle MUC avatars if stanza.xep0045_muc_user is not None: self._metadata_cache.pop(full_jid, None) def _cook_vcard_notify(self, jid, stanza): result = [] # note: an empty photo element correctly # results in an empty avatar metadata list if stanza.xep0153_x.photo: result.append( VCardAvatarDescriptor( remote_jid=jid, id_=stanza.xep0153_x.photo, mime_type=None, vcard=self._vcard, nbytes=None, ) ) return result def _cook_metadata(self, jid, items): def iter_metadata_info_nodes(items): for item in items: yield from item.registered_payload.iter_info_nodes() result = [] for info_node in iter_metadata_info_nodes(items): if info_node.url is not None: descriptor = HttpAvatarDescriptor( remote_jid=jid, id_=info_node.id_, mime_type=info_node.mime_type, nbytes=info_node.nbytes, width=info_node.width, height=info_node.height, url=info_node.url, ) else: descriptor = PubsubAvatarDescriptor( remote_jid=jid, id_=info_node.id_, mime_type=info_node.mime_type, nbytes=info_node.nbytes, width=info_node.width, height=info_node.height, pubsub=self._pubsub, ) result.append(descriptor) return result @service.attrsignal(avatar_pep, "on_item_publish") def _handle_pubsub_publish(self, jid, node, item, *, message=None): # update the metadata cache metadata = self._cook_metadata(jid, [item]) self._has_pep_avatar.add(jid) self._update_metadata(jid, metadata) async def _get_avatar_metadata_vcard(self, jid): logger.debug("trying vCard avatar as fallback for %s", jid) vcard = await self._vcard.get_vcard(jid) photo = vcard.get_photo_data() mime_type = vcard.get_photo_mime_type() if photo is None: return [] logger.debug("success vCard avatar as fallback for %s", jid) sha1 = hashlib.sha1() sha1.update(photo) return [VCardAvatarDescriptor( remote_jid=jid, id_=sha1.hexdigest(), mime_type=mime_type, nbytes=len(photo), vcard=self._vcard, image_bytes=photo, )] async def _get_avatar_metadata_pep(self, jid): try: metadata_raw = await self._pubsub.get_items( jid, namespaces.xep0084_metadata, max_items=1 ) except aioxmpp.XMPPCancelError as e: # transparently map feature-not-implemented and # item-not-found to be equivalent unset avatar if e.condition in ( aioxmpp.ErrorCondition.FEATURE_NOT_IMPLEMENTED, aioxmpp.ErrorCondition.ITEM_NOT_FOUND): return [] raise self._has_pep_avatar.add(jid) return self._cook_metadata(jid, metadata_raw.payload.items) async def get_avatar_metadata(self, jid, *, require_fresh=False, disable_pep=False): """ Retrieve a list of avatar descriptors. :param jid: the JID for which to retrieve the avatar metadata. :type jid: :class:`aioxmpp.JID` :param require_fresh: if true, do not return results from the avatar metadata cache, but retrieve them again from the server. :type require_fresh: :class:`bool` :param disable_pep: if true, do not try to retrieve the avatar via pep, only try the vCard fallback. This usually only useful when querying avatars via MUC, where the PEP request would be invalid (since it would be for a full jid). :type disable_pep: :class:`bool` :returns: an iterable of avatar descriptors. :rtype: a :class:`list` of :class:`~aioxmpp.avatar.service.AbstractAvatarDescriptor` instances Returning an empty list means that the avatar not set. We mask a :class:`XMPPCancelError` in the case that it is ``feature-not-implemented`` or ``item-not-found`` and return an empty list of avatar descriptors, since this is semantically equivalent to not having an avatar. .. note:: It is usually an error to get the avatar for a full jid, normally, the avatar is set for the bare jid of a user. The exception are vCard avatars over MUC, where the IQ requests for the vCard may be translated by the MUC server. It is recommended to use the `disable_pep` option in that case. """ if require_fresh: self._metadata_cache.pop(jid, None) else: try: return self._metadata_cache[jid] except KeyError: pass if disable_pep: metadata = [] else: metadata = await self._get_avatar_metadata_pep(jid) # try the vcard fallback, note: we don't try this # if the PEP avatar is disabled! if not metadata and jid not in self._has_pep_avatar: metadata = await self._get_avatar_metadata_vcard(jid) # if a notify was fired while we waited for the results, then # use the version in the cache, this will mitigate the race # condition because if our version is actually newer we will # soon get another notify for this version change! if jid not in self._metadata_cache: self._update_metadata(jid, metadata) return self._metadata_cache[jid] async def subscribe(self, jid): """ Explicitly subscribe to metadata change notifications for `jid`. """ await self._pubsub.subscribe(jid, namespaces.xep0084_metadata) @aioxmpp.service.depsignal(aioxmpp.stream.StanzaStream, "on_stream_destroyed") def handle_stream_destroyed(self, reason): self._metadata_cache.clear() self._vcard_resource_interference.clear() self._has_pep_avatar.clear() async def publish_avatar_set(self, avatar_set): """ Make `avatar_set` the current avatar of the jid associated with this connection. If :attr:`synchronize_vcard` is true and PEP is available the vCard is only synchronized if the PEP update is successful. This means publishing the ``image/png`` avatar data and the avatar metadata set in pubsub. The `avatar_set` must be an instance of :class:`AvatarSet`. If :attr:`synchronize_vcard` is true the avatar is additionally published in the user vCard. """ id_ = avatar_set.png_id done = False async with self._publish_lock: if await self._pep.available(): await self._pep.publish( namespaces.xep0084_data, avatar_xso.Data(avatar_set.image_bytes), id_=id_ ) await self._pep.publish( namespaces.xep0084_metadata, avatar_set.metadata, id_=id_ ) done = True if self._synchronize_vcard: my_vcard = await self._vcard.get_vcard() my_vcard.set_photo_data("image/png", avatar_set.image_bytes) self._vcard_id = avatar_set.png_id await self._vcard.set_vcard(my_vcard) self._presence_server.resend_presence() done = True if not done: raise RuntimeError( "failed to publish avatar: no protocol available" ) async def _disable_vcard_avatar(self): my_vcard = await self._vcard.get_vcard() my_vcard.clear_photo_data() self._vcard_id = "" await self._vcard.set_vcard(my_vcard) self._presence_server.resend_presence() async def disable_avatar(self): """ Temporarily disable the avatar. If :attr:`synchronize_vcard` is true, the vCard avatar is disabled (even if disabling the PEP avatar fails). This is done by setting the avatar metadata node empty and if :attr:`synchronize_vcard` is true, downloading the vCard, removing the avatar data and re-uploading the vCard. This method does not error if neither protocol is active. :raises aioxmpp.errors.GatherError: if an exception is raised by the spawned tasks. """ async with self._publish_lock: todo = [] if self._synchronize_vcard: todo.append(self._disable_vcard_avatar()) if await self._pep.available(): todo.append(self._pep.publish( namespaces.xep0084_metadata, avatar_xso.Metadata() )) await gather_reraise_multi(*todo, message="disable_avatar") async def wipe_avatar(self): """ Remove all avatar data stored on the server. If :attr:`synchronize_vcard` is true, the vCard avatar is disabled even if disabling the PEP avatar fails. This is equivalent to :meth:`disable_avatar` for vCard-based avatars, but will also remove the data PubSub node for PEP avatars. This method does not error if neither protocol is active. :raises aioxmpp.errors.GatherError: if an exception is raised by the spawned tasks. """ async def _wipe_pep_avatar(): await self._pep.publish( namespaces.xep0084_metadata, avatar_xso.Metadata() ) await self._pep.publish( namespaces.xep0084_data, avatar_xso.Data(b'') ) async with self._publish_lock: todo = [] if self._synchronize_vcard: todo.append(self._disable_vcard_avatar()) if await self._pep.available(): todo.append(_wipe_pep_avatar()) await gather_reraise_multi(*todo, message="wipe_avatar") aioxmpp/avatar/xso.py000066400000000000000000000130751416014621300152000ustar00rootroot00000000000000######################################################################## # 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/000077500000000000000000000000001416014621300145105ustar00rootroot00000000000000aioxmpp/benchtest/__init__.py000066400000000000000000000204441416014621300166250ustar00rootroot00000000000000######################################################################## # 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__.py000066400000000000000000000017211416014621300166030ustar00rootroot00000000000000######################################################################## # 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/000077500000000000000000000000001416014621300143215ustar00rootroot00000000000000aioxmpp/blocking/__init__.py000066400000000000000000000024441416014621300164360ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.blocking` --- Blocking Command support (:xep:`0191`) ################################################################### This subpackage provides client side support for :xep:`0191`. The public interface of this package consists of a single :class:`~aioxmpp.Service`: .. currentmodule:: aioxmpp .. autoclass:: BlockingClient .. currentmodule:: aioxmpp.blocking """ from .service import BlockingClient # NOQA: F401 aioxmpp/blocking/service.py000066400000000000000000000167761416014621300163540ustar00rootroot00000000000000######################################################################## # File name: service.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio import aioxmpp import aioxmpp.callbacks as callbacks import aioxmpp.service as service from aioxmpp.utils import namespaces from . import xso as blocking_xso class BlockingClient(service.Service): """ A :class:`~aioxmpp.service.Service` implementing :xep:`Blocking Command <191>`. This service maintains the list of blocked JIDs and allows manipulating the blocklist. Attribute: .. autoattribute:: blocklist Signals: .. signal:: on_initial_blocklist_received(blocklist) Fires when the initial blocklist was received from the server. :param blocklist: the initial blocklist :type blocklist: :class:`~collections.abc.Set` of :class:`~aioxmpp.JID` .. signal:: on_jids_blocked(blocked_jids) Fires when additional JIDs are blocked. :param blocked_jids: the newly blocked JIDs :type blocked_jids: :class:`~collections.abc.Set` of :class:`~aioxmpp.JID` .. signal:: on_jids_blocked(blocked_jids) Fires when JIDs are unblocked. :param unblocked_jids: the now unblocked JIDs :type unblocked_jids: :class:`~collections.abc.Set` of :class:`~aioxmpp.JID` Coroutine methods: .. automethod:: block_jids .. automethod:: unblock_jids .. automethod:: unblock_all """ ORDER_AFTER = [aioxmpp.DiscoClient] def __init__(self, client, **kwargs): super().__init__(client, **kwargs) self._blocklist = None self._lock = asyncio.Lock() self._disco = self.dependencies[aioxmpp.DiscoClient] on_jids_blocked = callbacks.Signal() on_jids_unblocked = callbacks.Signal() on_initial_blocklist_received = callbacks.Signal() async def _check_for_blocking(self): server_info = await self._disco.query_info( self.client.local_jid.replace( resource=None, localpart=None, ) ) if namespaces.xep0191 not in server_info.features: self._blocklist = None raise RuntimeError("server does not support blocklists!") @service.depsignal(aioxmpp.Client, "before_stream_established") async def _get_initial_blocklist(self): try: await self._check_for_blocking() except RuntimeError: self.logger.info( "server does not support block lists, skipping initial fetch" ) return True if self._blocklist is None: async with self._lock: iq = aioxmpp.IQ( type_=aioxmpp.IQType.GET, payload=blocking_xso.BlockList(), ) result = await self.client.send(iq) self._blocklist = frozenset(result.items) self.on_initial_blocklist_received(self._blocklist) return True @property def blocklist(self): """ :class:`~collections.abc.Set` of JIDs blocked by the account. """ return self._blocklist async def block_jids(self, jids_to_block): """ Add the JIDs in the sequence `jids_to_block` to the client's blocklist. """ await self._check_for_blocking() if not jids_to_block: return cmd = blocking_xso.BlockCommand(jids_to_block) iq = aioxmpp.IQ( type_=aioxmpp.IQType.SET, payload=cmd, ) await self.client.send(iq) async def unblock_jids(self, jids_to_unblock): """ Remove the JIDs in the sequence `jids_to_block` from the client's blocklist. """ await self._check_for_blocking() if not jids_to_unblock: return cmd = blocking_xso.UnblockCommand(jids_to_unblock) iq = aioxmpp.IQ( type_=aioxmpp.IQType.SET, payload=cmd, ) await self.client.send(iq) async def unblock_all(self): """ Unblock all JIDs currently blocked. """ await self._check_for_blocking() cmd = blocking_xso.UnblockCommand() iq = aioxmpp.IQ( type_=aioxmpp.IQType.SET, payload=cmd, ) await self.client.send(iq) @service.iq_handler(aioxmpp.IQType.SET, blocking_xso.BlockCommand) async def handle_block_push(self, block_command): diff = () async with self._lock: if self._blocklist is None: # this means the stream was destroyed while we were waiting for # the lock/while the handler was enqueued for scheduling, or # the server is buggy and sends pushes before we fetched the # blocklist return if (block_command.from_ is None or block_command.from_ == self.client.local_jid.bare() or # WORKAROUND: ejabberd#2287 block_command.from_ == self.client.local_jid): diff = frozenset(block_command.payload.items) self._blocklist |= diff else: self.logger.debug( "received block push from unauthorized JID: %s", block_command.from_, ) if diff: self.on_jids_blocked(diff) @service.iq_handler(aioxmpp.IQType.SET, blocking_xso.UnblockCommand) async def handle_unblock_push(self, unblock_command): diff = () async with self._lock: if self._blocklist is None: # this means the stream was destroyed while we were waiting for # the lock/while the handler was enqueued for scheduling, or # the server is buggy and sends pushes before we fetched the # blocklist return if (unblock_command.from_ is None or unblock_command.from_ == self.client.local_jid.bare() or # WORKAROUND: ejabberd#2287 unblock_command.from_ == self.client.local_jid): if not unblock_command.payload.items: diff = frozenset(self._blocklist) self._blocklist = frozenset() else: diff = frozenset(unblock_command.payload.items) self._blocklist -= diff else: self.logger.debug( "received unblock push from unauthorized JID: %s", unblock_command.from_, ) if diff: self.on_jids_unblocked(diff) @service.depsignal(aioxmpp.stream.StanzaStream, "on_stream_destroyed") def handle_stream_destroyed(self, reason): self._blocklist = None aioxmpp/blocking/xso.py000066400000000000000000000074141416014621300155120ustar00rootroot00000000000000######################################################################## # 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/000077500000000000000000000000001416014621300145215ustar00rootroot00000000000000aioxmpp/bookmarks/__init__.py000066400000000000000000000042151416014621300166340ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.bookmarks` – Bookmark support (:xep:`0048`) ########################################################## This module provides support for storing and retrieving bookmarks on the server as per :xep:`Bookmarks <48>`. Service ======= .. currentmodule:: aioxmpp .. autoclass:: BookmarkClient .. currentmodule:: aioxmpp.bookmarks XSOs ==== All bookmark types must adhere to the following ABC: .. autoclass:: Bookmark The following XSOs are used to represent an manipulate bookmark lists. .. autoclass:: Conference .. autoclass:: URL To register custom bookmark classes use: .. autofunction:: as_bookmark_class The following is used internally as the XSO container for bookmarks. .. autoclass:: Storage Notes on usage ============== .. currentmodule:: aioxmpp It is highly recommended to interact with the bookmark client via the provided signals and the get-modify-set methods :meth:`~BookmarkClient.add_bookmark`, :meth:`~BookmarkClient.discard_bookmark` and :meth:`~BookmarkClient.update_bookmark`. Using :meth:`~BookmarkClient.set_bookmarks` directly is error prone and might cause data loss due to race conditions. """ from .xso import (Storage, Bookmark, Conference, URL, # NOQA: F401 as_bookmark_class) from .service import BookmarkClient # NOQA: F401 aioxmpp/bookmarks/service.py000066400000000000000000000431171416014621300165410ustar00rootroot00000000000000######################################################################## # File name: service.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio import aioxmpp import aioxmpp.callbacks as callbacks import aioxmpp.service as service import aioxmpp.private_xml as private_xml from . import xso as bookmark_xso # TODO: use private storage in pubsub where available. # TODO: sync bookmarks between pubsub and private xml storage # TODO: do we need merge-capabilities to reconcile the bookmarks # from different sources (local bookmark storage, pubsub, private xml # storage) class BookmarkClient(service.Service): """ Supports retrieval and storage of bookmarks on the server. It currently only supports :xep:`Private XML Storage <49>` as backend. There is the general rule *never* to modify the bookmark instances retrieved from this class (either by :meth:`get_bookmarks` or as an argument to one of the signals). If you need to modify a bookmark for use with :meth:`update_bookmark` use :func:`copy.copy` to create a copy. .. automethod:: sync .. automethod:: get_bookmarks .. automethod:: set_bookmarks The following methods change the bookmark list in a get-modify-set pattern, to mitigate the danger of race conditions and should be used in most circumstances: .. automethod:: add_bookmark .. automethod:: discard_bookmark .. automethod:: update_bookmark The following signals are provided that allow tracking the changes to the bookmark list: .. signal:: on_bookmark_added(added_bookmark) Fires when a new bookmark is added. .. signal:: on_bookmark_removed(removed_bookmark) Fires when a bookmark is removed. .. signal:: on_bookmark_changed(old_bookmark, new_bookmark) Fires when a bookmark is changed. .. note:: A heuristic is used to determine the change of bookmarks and the reported changes may not directly reflect the used methods, but it will always be possible to construct the list of bookmarks from the events. For example, when using :meth:`update_bookmark` to change the JID of a :class:`Conference` bookmark a removed and a added signal will fire. .. note:: The bookmark protocol is prone to race conditions if several clients access it concurrently. Be careful to use a get-modify-set pattern or the provided highlevel interface. .. note:: Some other clients extend the bookmark format. For now those extensions are silently dropped by our XSOs, and therefore are lost, when changing the bookmarks with aioxmpp. This is considered a bug to be fixed in the future. """ ORDER_AFTER = [ private_xml.PrivateXMLService, ] on_bookmark_added = callbacks.Signal() on_bookmark_removed = callbacks.Signal() on_bookmark_changed = callbacks.Signal() def __init__(self, client, **kwargs): super().__init__(client, **kwargs) self._private_xml = self.dependencies[private_xml.PrivateXMLService] self._bookmark_cache = [] self._lock = asyncio.Lock() @service.depsignal(aioxmpp.Client, "on_stream_established", defer=True) async def _stream_established(self): await self.sync() async def _get_bookmarks(self): """ Get the stored bookmarks from the server. :returns: a list of bookmarks """ res = await self._private_xml.get_private_xml( bookmark_xso.Storage() ) return res.registered_payload.bookmarks async def _set_bookmarks(self, bookmarks): """ Set the bookmarks stored on the server. """ storage = bookmark_xso.Storage() storage.bookmarks[:] = bookmarks await self._private_xml.set_private_xml(storage) def _diff_emit_update(self, new_bookmarks): """ Diff the bookmark cache and the new bookmark state, emit signals as needed and set the bookmark cache to the new data. """ self.logger.debug("diffing %s, %s", self._bookmark_cache, new_bookmarks) def subdivide(level, old, new): """ Subdivide the bookmarks according to the data item ``bookmark.secondary[level]`` and emit the appropriate events. """ if len(old) == len(new) == 1: old_entry = old.pop() new_entry = new.pop() if old_entry == new_entry: pass else: self.on_bookmark_changed(old_entry, new_entry) return ([], []) elif len(old) == 0: return ([], new) elif len(new) == 0: return (old, []) else: try: groups = {} for entry in old: group = groups.setdefault( entry.secondary[level], ([], []) ) group[0].append(entry) for entry in new: group = groups.setdefault( entry.secondary[level], ([], []) ) group[1].append(entry) except IndexError: # the classification is exhausted, this means # all entries in this bin are equal by the # definition of bookmark equivalence! common = min(len(old), len(new)) assert old[:common] == new[:common] return (old[common:], new[common:]) old_unhandled, new_unhandled = [], [] for old, new in groups.values(): unhandled = subdivide(level+1, old, new) old_unhandled += unhandled[0] new_unhandled += unhandled[1] # match up unhandleds as changes as early as possible i = -1 for i, (old_entry, new_entry) in enumerate( zip(old_unhandled, new_unhandled)): self.logger.debug("changed %s -> %s", old_entry, new_entry) self.on_bookmark_changed(old_entry, new_entry) i += 1 return old_unhandled[i:], new_unhandled[i:] # group the bookmarks into groups whose elements may transform # among one another by on_bookmark_changed events. This information # is given by the type of the bookmark and the .primary property changable_groups = {} for item in self._bookmark_cache: group = changable_groups.setdefault( (type(item), item.primary), ([], []) ) group[0].append(item) for item in new_bookmarks: group = changable_groups.setdefault( (type(item), item.primary), ([], []) ) group[1].append(item) for old, new in changable_groups.values(): # the first branches are fast paths which should catch # most cases – especially all cases where each bare jid of # a conference bookmark or each url of an url bookmark is # only used in one bookmark if len(old) == len(new) == 1: old_entry = old.pop() new_entry = new.pop() if old_entry == new_entry: # the bookmark is unchanged, do not emit an event pass else: self.logger.debug("changed %s -> %s", old_entry, new_entry) self.on_bookmark_changed(old_entry, new_entry) elif len(new) == 0: for removed in old: self.logger.debug("removed %s", removed) self.on_bookmark_removed(removed) elif len(old) == 0: for added in new: self.logger.debug("added %s", added) self.on_bookmark_added(added) else: old, new = subdivide(0, old, new) assert len(old) == 0 or len(new) == 0 for removed in old: self.logger.debug("removed %s", removed) self.on_bookmark_removed(removed) for added in new: self.logger.debug("added %s", added) self.on_bookmark_added(added) self._bookmark_cache = new_bookmarks async def get_bookmarks(self): """ Get the stored bookmarks from the server. Causes signals to be fired to reflect the changes. :returns: a list of bookmarks """ async with self._lock: bookmarks = await self._get_bookmarks() self._diff_emit_update(bookmarks) return bookmarks async def set_bookmarks(self, bookmarks): """ Store the sequence of bookmarks `bookmarks`. Causes signals to be fired to reflect the changes. .. note:: This should normally not be used. It does not mitigate the race condition between clients concurrently modifying the bookmarks and may lead to data loss. Use :meth:`add_bookmark`, :meth:`discard_bookmark` and :meth:`update_bookmark` instead. This method still has use-cases (modifying the bookmarklist at large, e.g. by syncing the remote store with local data). """ async with self._lock: await self._set_bookmarks(bookmarks) self._diff_emit_update(bookmarks) async def sync(self): """ Sync the bookmarks between the local representation and the server. This must be called periodically to assure that the signals are fired. """ await self.get_bookmarks() async def add_bookmark(self, new_bookmark, *, max_retries=3): """ Add a bookmark and check whether it was successfully added to the bookmark list. Already existent bookmarks are not added twice. :param new_bookmark: the bookmark to add :type new_bookmark: an instance of :class:`~bookmark_xso.Bookmark` :param max_retries: the number of retries if setting the bookmark fails :type max_retries: :class:`int` :raises RuntimeError: if the bookmark is not in the bookmark list after `max_retries` retries. After setting the bookmark it is checked, whether the bookmark is in the online storage, if it is not it is tried again at most `max_retries` times to add the bookmark. A :class:`RuntimeError` is raised if the bookmark could not be added successfully after `max_retries`. """ async with self._lock: bookmarks = await self._get_bookmarks() try: modified_bookmarks = list(bookmarks) if new_bookmark not in bookmarks: modified_bookmarks.append(new_bookmark) await self._set_bookmarks(modified_bookmarks) retries = 0 bookmarks = await self._get_bookmarks() while retries < max_retries: if new_bookmark in bookmarks: break modified_bookmarks = list(bookmarks) modified_bookmarks.append(new_bookmark) await self._set_bookmarks(modified_bookmarks) bookmarks = await self._get_bookmarks() retries += 1 if new_bookmark not in bookmarks: raise RuntimeError("Could not add bookmark") finally: self._diff_emit_update(bookmarks) async def discard_bookmark(self, bookmark_to_remove, *, max_retries=3): """ Remove a bookmark and check it has been removed. :param bookmark_to_remove: the bookmark to remove :type bookmark_to_remove: a :class:`~bookmark_xso.Bookmark` subclass. :param max_retries: the number of retries of removing the bookmark fails. :type max_retries: :class:`int` :raises RuntimeError: if the bookmark is not removed from bookmark list after `max_retries` retries. If there are multiple occurrences of the same bookmark exactly one is removed. This does nothing if the bookmarks does not match an existing bookmark according to bookmark-equality. After setting the bookmark it is checked, whether the bookmark is removed in the online storage, if it is not it is tried again at most `max_retries` times to remove the bookmark. A :class:`RuntimeError` is raised if the bookmark could not be removed successfully after `max_retries`. """ async with self._lock: bookmarks = await self._get_bookmarks() occurrences = bookmarks.count(bookmark_to_remove) try: if not occurrences: return modified_bookmarks = list(bookmarks) modified_bookmarks.remove(bookmark_to_remove) await self._set_bookmarks(modified_bookmarks) retries = 0 bookmarks = await self._get_bookmarks() new_occurences = bookmarks.count(bookmark_to_remove) while retries < max_retries: if new_occurences < occurrences: break modified_bookmarks = list(bookmarks) modified_bookmarks.remove(bookmark_to_remove) await self._set_bookmarks(modified_bookmarks) bookmarks = await self._get_bookmarks() new_occurences = bookmarks.count(bookmark_to_remove) retries += 1 if new_occurences >= occurrences: raise RuntimeError("Could not remove bookmark") finally: self._diff_emit_update(bookmarks) async def update_bookmark(self, old, new, *, max_retries=3): """ Update a bookmark and check it was successful. The bookmark matches an existing bookmark `old` according to bookmark equalitiy and replaces it by `new`. The bookmark `new` is added if no bookmark matching `old` exists. :param old: the bookmark to replace :type bookmark_to_remove: a :class:`~bookmark_xso.Bookmark` subclass. :param new: the replacement bookmark :type bookmark_to_remove: a :class:`~bookmark_xso.Bookmark` subclass. :param max_retries: the number of retries of removing the bookmark fails. :type max_retries: :class:`int` :raises RuntimeError: if the bookmark is not in the bookmark list after `max_retries` retries. After replacing the bookmark it is checked, whether the bookmark `new` is in the online storage, if it is not it is tried again at most `max_retries` times to replace the bookmark. A :class:`RuntimeError` is raised if the bookmark could not be replaced successfully after `max_retries`. .. note:: Do not modify a bookmark retrieved from the signals or from :meth:`get_bookmarks` to obtain the bookmark `new`, this will lead to data corruption as they are passed by reference. Instead use :func:`copy.copy` and modify the copy. """ def replace_bookmark(bookmarks, old, new): modified_bookmarks = list(bookmarks) try: i = bookmarks.index(old) modified_bookmarks[i] = new except ValueError: modified_bookmarks.append(new) return modified_bookmarks async with self._lock: bookmarks = await self._get_bookmarks() try: await self._set_bookmarks( replace_bookmark(bookmarks, old, new) ) retries = 0 bookmarks = await self._get_bookmarks() while retries < max_retries: if new in bookmarks: break await self._set_bookmarks( replace_bookmark(bookmarks, old, new) ) bookmarks = await self._get_bookmarks() retries += 1 if new not in bookmarks: raise RuntimeError("Cold not update bookmark") finally: self._diff_emit_update(bookmarks) aioxmpp/bookmarks/xso.py000066400000000000000000000157411416014621300157140ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000114451416014621300141530ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000652351416014621300150350ustar00rootroot00000000000000######################################################################## # File name: callbacks.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.callbacks` -- Synchronous and asynchronous callbacks ################################################################### This module provides facilities for objects to provide signals to which other objects can connect. Descriptor vs. ad-hoc ===================== Descriptors can be used as class attributes and will create ad-hoc signals dynamically for each instance. They are the most commonly used: .. code-block:: python class Emitter: on_event = callbacks.Signal() def handler(): pass emitter1 = Emitter() emitter2 = Emitter() emitter1.on_event.connect(handler) emitter1.on_event() # calls `handler` emitter2.on_event() # does not call `handler` # the actual signals are distinct assert emitter1.on_event is not emitter2.on_event Ad-hoc signals are useful for testing and are the type of which the actual fields are. Signal overview =============== .. autosummary:: Signal SyncSignal AdHocSignal SyncAdHocSignal Utilities --------- .. autofunction:: first_signal Signal descriptors ------------------ These descriptors can be used on classes to have attributes which are signals: .. autoclass:: Signal .. autoclass:: SyncSignal Signal implementations (ad-hoc signals) --------------------------------------- Whenever accessing an attribute using the :class:`Signal` or :class:`SyncSignal` descriptors, an object of one of the following classes is returned. This is where the behaviour of the signals is specified. .. autoclass:: AdHocSignal .. autoclass:: SyncAdHocSignal Filters ======= .. autoclass:: Filter """ import abc import asyncio import collections import contextlib import functools import logging import types import weakref logger = logging.getLogger(__name__) def log_spawned(logger, fut): try: result = fut.result() except asyncio.CancelledError: logger.debug("spawned task was cancelled") except: # NOQA logger.warning("spawned task raised exception", exc_info=True) else: if result is not None: logger.info("value returned by spawned task was ignored: %r", result) class TagListener: def __init__(self, ondata, onerror=None): self._ondata = ondata self._onerror = onerror def data(self, data): return self._ondata(data) def error(self, exc): if self._onerror is not None: return self._onerror(exc) def is_valid(self): return True class AsyncTagListener(TagListener): def __init__(self, ondata, onerror=None, *, loop=None): super().__init__(ondata, onerror) self._loop = loop or asyncio.get_event_loop() def data(self, data): self._loop.call_soon(self._ondata, data) def error(self, exc): if self._onerror is not None: self._loop.call_soon(self._onerror, exc) class OneshotTagListener(TagListener): def __init__(self, ondata, onerror=None, **kwargs): super().__init__(ondata, onerror=onerror, **kwargs) self._cancelled = False def data(self, data): super().data(data) return True def error(self, exc): super().error(exc) return True def cancel(self): self._cancelled = True def is_valid(self): return not self._cancelled and super().is_valid() class OneshotAsyncTagListener(OneshotTagListener, AsyncTagListener): pass class FutureListener: def __init__(self, fut): self.fut = fut def data(self, data): try: self.fut.set_result(data) except asyncio.InvalidStateError: pass return True def error(self, exc): try: self.fut.set_exception(exc) except asyncio.InvalidStateError: pass return True def is_valid(self): return not self.fut.done() class TagDispatcher: def __init__(self): self._listeners = {} def add_callback(self, tag, fn): return self.add_listener(tag, TagListener(fn)) def add_callback_async(self, tag, fn, *, loop=None): return self.add_listener( tag, AsyncTagListener(fn, loop=loop) ) def add_future(self, tag, fut): return self.add_listener( tag, FutureListener(fut) ) def add_listener(self, tag, listener): try: existing = self._listeners[tag] if not existing.is_valid(): raise KeyError() except KeyError: self._listeners[tag] = listener else: raise ValueError("only one listener is allowed per tag") def unicast(self, tag, data): cb = self._listeners[tag] if not cb.is_valid(): del self._listeners[tag] self._listeners[tag] if cb.data(data): del self._listeners[tag] def unicast_error(self, tag, exc): cb = self._listeners[tag] if not cb.is_valid(): del self._listeners[tag] self._listeners[tag] if cb.error(exc): del self._listeners[tag] def remove_listener(self, tag): del self._listeners[tag] def broadcast_error(self, exc): for tag, listener in list(self._listeners.items()): if listener.is_valid() and listener.error(exc): del self._listeners[tag] def close_all(self, exc): self.broadcast_error(exc) self._listeners.clear() class AbstractAdHocSignal: def __init__(self): super().__init__() self._connections = collections.OrderedDict() self.logger = logger def _connect(self, wrapper): token = object() self._connections[token] = wrapper return token def disconnect(self, token): """ Disconnect the connection identified by `token`. This never raises, even if an invalid `token` is passed. """ try: del self._connections[token] except KeyError: pass class AdHocSignal(AbstractAdHocSignal): """ An ad-hoc signal is a single emitter. This is where callables are connected to, using the :meth:`connect` method of the :class:`AdHocSignal`. .. automethod:: fire .. automethod:: connect .. automethod:: context_connect .. automethod:: future .. attribute:: logger This may be a :class:`logging.Logger` instance to allow the signal to log errors and debug events to a specific logger instead of the default logger (``aioxmpp.callbacks``). This attribute must not be :data:`None`, and it is initialised to the default logger on creation of the :class:`AdHocSignal`. The different ways callables can be connected to an ad-hoc signal are shown below: .. attribute:: STRONG Connections using this mode keep a strong reference to the callable. The callable is called directly, thus blocking the emission of the signal. .. attribute:: WEAK Connections using this mode keep a weak reference to the callable. The callable is executed directly, thus blocking the emission of the signal. If the weak reference is dead, it is automatically removed from the signals connection list. If the callable is a bound method, :class:`weakref.WeakMethod` is used automatically. For both :attr:`STRONG` and :attr:`WEAK` holds: if the callable returns a true value, it is disconnected from the signal. .. classmethod:: ASYNC_WITH_LOOP(loop) This mode requires an :mod:`asyncio` event loop as argument. When the signal is emitted, the callable is not called directly. Instead, it is enqueued for calling with the event loop using :meth:`asyncio.BaseEventLoop.call_soon`. If :data:`None` is passed as `loop`, the loop is obtained from :func:`asyncio.get_event_loop` at connect time. A strong reference is held to the callable. Connections using this mode are never removed automatically from the signals connection list. You have to use :meth:`disconnect` explicitly. .. attribute:: AUTO_FUTURE Instead of a callable, a :class:`asyncio.Future` must be passed when using this mode. This mode can only be used for signals which send at most one positional argument. If no argument is sent, the :meth:`~asyncio.Future.set_result` method is called with :data:`None`. If one argument is sent and it is an instance of :class:`Exception`, it is passed to :meth:`~asyncio.Future.set_exception`. Otherwise, if one argument is sent, it is passed to :meth:`~asyncio.Future.set_exception`. In any case, the future is removed after the next emission of the signal. .. classmethod:: SPAWN_WITH_LOOP(loop) This mode requires an :mod:`asyncio` event loop as argument and a coroutine to be passed to :meth:`connect`. If :data:`None` is passed as `loop`, the loop is obtained from :func:`asyncio.get_event_loop` at connect time. When the signal is emitted, the coroutine is spawned using :func:`asyncio.ensure_future` in the given `loop`, with the arguments passed to the signal. A strong reference is held to the coroutine. Connections using this mode are never removed automatically from the signals connection list. You have to use :meth:`disconnect` explicitly. If the spawned coroutine returns with an exception or a non-:data:`None` return value, a message is logged, with the following log levels: * Return with non-:data:`None` value: :data:`logging.INFO` * Raises :class:`asyncio.CancelledError`: :data:`logging.DEBUG` * Raises any other exception: :data:`logging.WARNING` .. versionadded:: 0.6 .. automethod:: disconnect """ @classmethod def STRONG(cls, f): if not hasattr(f, "__call__"): raise TypeError("must be callable, got {!r}".format(f)) return functools.partial(cls._strong_wrapper, f) @classmethod def ASYNC_WITH_LOOP(cls, loop): if loop is None: loop = asyncio.get_event_loop() def create_wrapper(f): if not hasattr(f, "__call__"): raise TypeError("must be callable, got {!r}".format(f)) return functools.partial(cls._async_wrapper, f, loop) return create_wrapper @classmethod def WEAK(cls, f): if not hasattr(f, "__call__"): raise TypeError("must be callable, got {!r}".format(f)) if isinstance(f, types.MethodType): ref = weakref.WeakMethod(f) else: ref = weakref.ref(f) return functools.partial(cls._weakref_wrapper, ref) @classmethod def AUTO_FUTURE(cls, f): def future_wrapper(args, kwargs): if len(args) > 0: try: arg, = args except ValueError: raise TypeError("too many arguments") from None else: arg = None if f.done(): return if isinstance(arg, Exception): f.set_exception(arg) else: f.set_result(arg) return future_wrapper @classmethod def SPAWN_WITH_LOOP(cls, loop): loop = asyncio.get_event_loop() if loop is None else loop def spawn(f): if not asyncio.iscoroutinefunction(f): raise TypeError("must be coroutine, got {!r}".format(f)) def wrapper(args, kwargs): task = asyncio.ensure_future(f(*args, **kwargs), loop=loop) task.add_done_callback( functools.partial( log_spawned, logger, ) ) return True return wrapper return spawn @staticmethod def _async_wrapper(f, loop, args, kwargs): if kwargs: functools.partial(f, *args, **kwargs) loop.call_soon(f, *args) return True @staticmethod def _weakref_wrapper(fref, args, kwargs): f = fref() if f is None: return False return not f(*args, **kwargs) @staticmethod def _strong_wrapper(f, args, kwargs): return not f(*args, **kwargs) def connect(self, f, mode=None): """ Connect an object `f` to the signal. The type the object needs to have depends on `mode`, but usually it needs to be a callable. :meth:`connect` returns an opaque token which can be used with :meth:`disconnect` to disconnect the object from the signal. The default value for `mode` is :attr:`STRONG`. Any decorator can be used as argument for `mode` and it is applied to `f`. The result is stored internally and is what will be called when the signal is being emitted. If the result of `mode` returns a false value during emission, the connection is removed. .. note:: The return values required by the callable returned by `mode` and the one required by a callable passed to `f` using the predefined modes are complementary! A callable `f` needs to return true to be removed from the connections, while a callable returned by the `mode` decorator needs to return false. Existing modes are listed below. """ mode = mode or self.STRONG self.logger.debug("connecting %r with mode %r", f, mode) return self._connect(mode(f)) def context_connect(self, f, mode=None): """ This returns a *context manager*. When entering the context, `f` is connected to the :class:`AdHocSignal` using `mode`. When leaving the context (no matter whether with or without exception), the connection is disconnected. .. seealso:: The returned object is an instance of :class:`SignalConnectionContext`. """ return SignalConnectionContext(self, f, mode=mode) def fire(self, *args, **kwargs): """ Emit the signal, calling all connected objects in-line with the given arguments and in the order they were registered. :class:`AdHocSignal` provides full isolation with respect to exceptions. If a connected listener raises an exception, the other listeners are executed as normal, but the raising listener is removed from the signal. The exception is logged to :attr:`logger` and *not* re-raised, so that the caller of the signal is also not affected. Instead of calling :meth:`fire` explicitly, the ad-hoc signal object itself can be called, too. """ for token, wrapper in list(self._connections.items()): try: keep = wrapper(args, kwargs) except Exception: self.logger.exception("listener attached to signal raised") keep = False if not keep: del self._connections[token] def future(self): """ Return a :class:`asyncio.Future` which has been :meth:`connect`\\ -ed using :attr:`AUTO_FUTURE`. The token returned by :meth:`connect` is not returned; to remove the future from the signal, just cancel it. """ fut = asyncio.Future() self.connect(fut, self.AUTO_FUTURE) return fut __call__ = fire class SyncAdHocSignal(AbstractAdHocSignal): """ A synchronous ad-hoc signal is like :class:`AdHocSignal`, but for coroutines instead of ordinary callables. .. automethod:: connect .. automethod:: context_connect .. automethod:: fire .. automethod:: disconnect """ def connect(self, coro): """ The coroutine `coro` is connected to the signal. The coroutine must return a true value, unless it wants to be disconnected from the signal. .. note:: This is different from the return value convention with :attr:`AdHocSignal.STRONG` and :attr:`AdHocSignal.WEAK`. :meth:`connect` returns a token which can be used with :meth:`disconnect` to disconnect the coroutine. """ self.logger.debug("connecting %r", coro) return self._connect(coro) def context_connect(self, coro): """ This returns a *context manager*. When entering the context, `coro` is connected to the :class:`SyncAdHocSignal`. When leaving the context (no matter whether with or without exception), the connection is disconnected. .. seealso:: The returned object is an instance of :class:`SignalConnectionContext`. """ return SignalConnectionContext(self, coro) async def fire(self, *args, **kwargs): """ Emit the signal, calling all coroutines in-line with the given arguments and in the order they were registered. This is obviously a coroutine. Instead of calling :meth:`fire` explicitly, the ad-hoc signal object itself can be called, too. """ for token, coro in list(self._connections.items()): keep = await coro(*args, **kwargs) if not keep: del self._connections[token] __call__ = fire class SignalConnectionContext: def __init__(self, signal, *args, **kwargs): self._signal = signal self._args = args self._kwargs = kwargs def __enter__(self): try: token = self._signal.connect(*self._args, **self._kwargs) finally: del self._args del self._kwargs self._token = token return token def __exit__(self, exc_type, exc_value, traceback): self._signal.disconnect(self._token) return False class AbstractSignal(metaclass=abc.ABCMeta): def __init__(self, *, doc=None): super().__init__() self.__doc__ = doc self._instances = weakref.WeakKeyDictionary() @abc.abstractclassmethod def make_adhoc_signal(cls): pass def __get__(self, instance, owner): if instance is None: return self try: return self._instances[instance] except KeyError: new = self.make_adhoc_signal() self._instances[instance] = new return new def __set__(self, instance, value): raise AttributeError("cannot override Signal attribute") def __delete__(self, instance): raise AttributeError("cannot override Signal attribute") class Signal(AbstractSignal): """ A descriptor which returns per-instance :class:`AdHocSignal` objects on attribute access. Example use: .. code-block:: python class Foo: on_event = Signal() f = Foo() assert isinstance(f.on_event, AdHocSignal) assert f.on_event is f.on_event assert Foo().on_event is not f.on_event """ @classmethod def make_adhoc_signal(cls): return AdHocSignal() class SyncSignal(AbstractSignal): """ A descriptor which returns per-instance :class:`SyncAdHocSignal` objects on attribute access. Example use: .. code-block:: python class Foo: on_event = SyncSignal() f = Foo() assert isinstance(f.on_event, SyncAdHocSignal) assert f.on_event is f.on_event assert Foo().on_event is not f.on_event """ @classmethod def make_adhoc_signal(cls): return SyncAdHocSignal() class Filter: """ A filter chain for arbitrary data. This is used for example in :class:`~.stream.StanzaStream` to allow services and applications to filter inbound and outbound stanzas. Each function registered with the filter receives at least one argument. This argument is the object which is to be filtered. The function must return the object, a replacement or :data:`None`. If :data:`None` is returned, the filter chain aborts and further functions are not called. Otherwise, the next function is called with the result of the previous function until the filter chain is complete. Other arguments passed to :meth:`filter` are passed unmodified to each function called; only the first argument is subject to filtering. .. versionchanged:: 0.9 This class was formerly available at :class:`aioxmpp.stream.Filter`. .. automethod:: register .. automethod:: filter .. automethod:: unregister .. automethod:: context_register(func[, order]) """ class Token: def __str__(self): return "<{}.{} 0x{:x}>".format( type(self).__module__, type(self).__qualname__, id(self)) def __init__(self): super().__init__() self._filter_order = [] def register(self, func, order): """ Add a function to the filter chain. :param func: A callable which is to be added to the filter chain. :param order: An object indicating the ordering of the function relative to the others. :return: Token representing the registration. Register the function `func` as a filter into the chain. `order` must be a value which is used as a sorting key to order the functions registered in the chain. The type of `order` depends on the use of the filter, as does the number of arguments and keyword arguments which `func` must accept. This will generally be documented at the place where the :class:`Filter` is used. Functions with the same order are sorted in the order of their addition, with the function which was added earliest first. Remember that all values passed to `order` which are registered at the same time in the same :class:`Filter` need to be totally orderable with respect to each other. The returned token can be used to :meth:`unregister` a filter. """ token = self.Token() self._filter_order.append((order, token, func)) self._filter_order.sort(key=lambda x: x[0]) return token def filter(self, obj, *args, **kwargs): """ Filter the given object through the filter chain. :param obj: The object to filter :param args: Additional arguments to pass to each filter function. :param kwargs: Additional keyword arguments to pass to each filter function. :return: The filtered object or :data:`None` See the documentation of :class:`Filter` on how filtering operates. Returns the object returned by the last function in the filter chain or :data:`None` if any function returned :data:`None`. """ for _, _, func in self._filter_order: obj = func(obj, *args, **kwargs) if obj is None: return None return obj def unregister(self, token_to_remove): """ Unregister a filter function. :param token_to_remove: The token as returned by :meth:`register`. Unregister a function from the filter chain using the token returned by :meth:`register`. """ for i, (_, token, _) in enumerate(self._filter_order): if token == token_to_remove: break else: raise ValueError("unregistered token: {!r}".format( token_to_remove)) del self._filter_order[i] @contextlib.contextmanager def context_register(self, func, *args): """ :term:`Context manager ` which temporarily registers a filter function. :param func: The filter function to register. :param order: The sorting key for the filter function. :rtype: :term:`context manager` :return: Context manager which temporarily registers the filter function. If :meth:`register` does not require `order` because it has been overridden in a subclass, the `order` argument can be omitted here, too. .. versionadded:: 0.9 """ token = self.register(func, *args) try: yield finally: self.unregister(token) def first_signal(*signals): """ Connect to multiple signals and wait for the first to emit. :param signals: Signals to connect to. :type signals: :class:`AdHocSignal` :return: An awaitable for the first signal to emit. The awaitable returns the first argument passed to the signal. If the first argument is an exception, the exception is re-raised from the awaitable. A common use-case is a situation where a class exposes a "on_finished" type signal and an "on_failure" type signal. :func:`first_signal` can be used to combine those nicely:: # e.g. a aioxmpp.im.conversation.AbstractConversation conversation = ... await first_signal( # emits without arguments when the conversation is successfully # entered conversation.on_enter, # emits with an exception when entering the conversation fails conversation.on_failure, ) # await first_signal(...) will either raise an exception (failed) or # return None (success) .. warning:: Only works with signals which emit with zero or one argument. Signals which emit with more than one argument or with keyword arguments are silently ignored! (Thus, if only such signals are connected, the future will never complete.) (This is a side-effect of the implementation of :meth:`AdHocSignal.AUTO_FUTURE`). .. note:: Does not work with coroutine signals (:class:`SyncAdHocSignal`). """ fut = asyncio.Future() for signal in signals: signal.connect(fut, signal.AUTO_FUTURE) return fut aioxmpp/carbons/000077500000000000000000000000001416014621300141605ustar00rootroot00000000000000aioxmpp/carbons/__init__.py000066400000000000000000000040441416014621300162730ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.carbons` -- Message Carbons (:xep:`280`) ####################################################### Message Carbons is an XMPP extension which allows an entity to receive copies of inbound and outbound messages received and sent by other resources of the same account. It is specified in :xep:`280`. The goal of this feature is to allow users to have multiple devices which all have a consistent view on the messages sent and received. This subpackage provides basic support for Message Carbons. It allows enabling and disabling the feature at the server side. Service ======= .. currentmodule:: aioxmpp .. autoclass:: CarbonsClient .. currentmodule:: aioxmpp.carbons .. currentmodule:: aioxmpp.carbons.xso .. module:: aioxmpp.carbons.xso XSOs ==== .. attribute:: aioxmpp.Message.xep0280_sent On a Carbon message, this holds the :class:`~.carbons.xso.Sent` XSO which in turn holds the carbonated stanza. .. attribute:: aioxmpp.Message.xep0280_received On a Carbon message, this holds the :class:`~.carbons.xso.Received` XSO which in turn holds the carbonated stanza. .. autoclass:: Received .. autoclass:: Sent """ from .service import CarbonsClient # NOQA: F401 aioxmpp/carbons/service.py000066400000000000000000000064061416014621300162000ustar00rootroot00000000000000######################################################################## # File name: service.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio import aioxmpp.service from aioxmpp.utils import namespaces from . import xso as carbons_xso class CarbonsClient(aioxmpp.service.Service): """ Provide an interface to enable and disable Message Carbons on the server side. .. note:: This service deliberately does not provide a way to actually obtain sent or received carbonated messages. The common way for a service to do this would be a stanza filter (see :class:`aioxmpp.stream.StanzaStream`); however, in general the use and further distribution of carbonated messages highly depends on the application: it does, for example, not make sense to simply unwrap carbonated messages. .. automethod:: enable .. automethod:: disable """ ORDER_AFTER = [ aioxmpp.DiscoClient, ] async def _check_for_feature(self): disco_client = self.dependencies[aioxmpp.DiscoClient] info = await disco_client.query_info( self.client.local_jid.replace( localpart=None, resource=None, ) ) if namespaces.xep0280_carbons_2 not in info.features: raise RuntimeError( "Message Carbons ({}) are not supported by the server".format( namespaces.xep0280_carbons_2 ) ) async def enable(self): """ Enable message carbons. :raises RuntimeError: if the server does not support message carbons. :raises aioxmpp.XMPPError: if the server responded with an error to the request. :raises: as specified in :meth:`aioxmpp.Client.send` """ await self._check_for_feature() iq = aioxmpp.IQ( type_=aioxmpp.IQType.SET, payload=carbons_xso.Enable() ) await self.client.send(iq) async def disable(self): """ Disable message carbons. :raises RuntimeError: if the server does not support message carbons. :raises aioxmpp.XMPPError: if the server responded with an error to the request. :raises: as specified in :meth:`aioxmpp.Client.send` """ await self._check_for_feature() iq = aioxmpp.IQ( type_=aioxmpp.IQType.SET, payload=carbons_xso.Disable() ) await self.client.send(iq) aioxmpp/carbons/xso.py000066400000000000000000000056711416014621300153540ustar00rootroot00000000000000######################################################################## # 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/000077500000000000000000000000001416014621300146745ustar00rootroot00000000000000aioxmpp/chatstates/__init__.py000066400000000000000000000034631416014621300170130ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.chatstates` – Chat State Notification support (:xep:`0085`) ########################################################################## This module provides support to implement :xep:`Chat State Notifications <85>`. XSOs ==== The module registers an attribute ``xep0085_chatstate`` with :class:`aioxmpp.Message` to represent the chat state notification tags, it takes values from the following enumeration (or :data:`None` if no tag is present): .. autoclass:: ChatState Helpers ======= The module provides the following helper class, that handles the state management for chat state notifications: .. autoclass:: ChatStateManager Its operation is controlled by one of the chat state strategies: .. autoclass:: DoNotEmit .. autoclass:: DiscoverSupport .. autoclass:: AlwaysEmit """ from .xso import ChatState # NOQA: F401 from .utils import (ChatStateManager, DoNotEmit, AlwaysEmit, # NOQA: F401 DiscoverSupport) aioxmpp/chatstates/utils.py000066400000000000000000000100531416014621300164050ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000030661416014621300160640ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000274121416014621300151030ustar00rootroot00000000000000######################################################################## # File name: connector.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.connector` --- Ways to establish XML streams ########################################################### This module provides classes to establish XML streams. Currently, there are two different ways to establish XML streams: normal TCP connection which is then upgraded using STARTTLS, and directly using TLS. .. versionadded:: 0.6 The whole module was added in version 0.6. Abstract base class =================== The connectors share a common abstract base class, :class:`BaseConnector`: .. autoclass:: BaseConnector Specific connectors =================== .. autoclass:: STARTTLSConnector .. autoclass:: XMPPOverTLSConnector """ import abc import asyncio import logging from datetime import timedelta import aioxmpp.errors as errors import aioxmpp.nonza as nonza import aioxmpp.protocol as protocol import aioxmpp.ssl_transport as ssl_transport def to_ascii(s): return s.encode("idna").decode("ascii") class BaseConnector(metaclass=abc.ABCMeta): """ This is the base class for connectors. It defines the public interface of all connectors. .. autoattribute:: tls_supported .. automethod:: connect Existing connectors: .. autosummary:: STARTTLSConnector XMPPOverTLSConnector """ @abc.abstractproperty def tls_supported(self): """ Boolean which indicates whether TLS is supported by this connector. """ @abc.abstractproperty def dane_supported(self): """ Boolean which indicates whether DANE is supported by this connector. """ @abc.abstractmethod async def connect(self, loop, metadata, domain, host, port, negotiation_timeout, base_logger=None): """ Establish a :class:`.protocol.XMLStream` for `domain` with the given `host` at the given TCP `port`. `metadata` must be a :class:`.security_layer.SecurityLayer` instance to use for the connection. `loop` must be a :class:`asyncio.BaseEventLoop` to use. `negotiation_timeout` must be the maximum time in seconds to wait for the server to reply in each negotiation step. The `negotiation_timeout` is used as value for :attr:`~aioxmpp.protocol.XMLStream.deadtime_hard_limit` in the returned stream. Return a triple consisting of the :class:`asyncio.Transport`, the :class:`.protocol.XMLStream` and the :class:`aioxmpp.nonza.StreamFeatures` of the stream. To detect the use of TLS on the stream, check whether :meth:`asyncio.Transport.get_extra_info` returns a non-:data:`None` value for ``"ssl_object"``. `base_logger` is passed to :class:`aioxmpp.protocol.XMLStream`. .. versionchanged:: 0.10 Assignment of :attr:`~aioxmpp.protocol.XMLStream.deadtime_hard_limit` was added. """ class STARTTLSConnector(BaseConnector): """ Establish an XML stream using STARTTLS. .. automethod:: connect """ @property def tls_supported(self): return True @property def dane_supported(self): return False async def connect(self, loop, metadata, domain: str, host, port, negotiation_timeout, base_logger=None): """ .. seealso:: :meth:`BaseConnector.connect` For general information on the :meth:`connect` method. Connect to `host` at TCP port number `port`. The :class:`aioxmpp.security_layer.SecurityLayer` object `metadata` is used to determine the parameters of the TLS connection. First, a normal TCP connection is opened and the stream header is sent. The stream features are waited for, and then STARTTLS is negotiated if possible. :attr:`~.security_layer.SecurityLayer.tls_required` is honoured: if it is true and TLS negotiation fails, :class:`~.errors.TLSUnavailable` is raised. TLS negotiation is always attempted if :attr:`~.security_layer.SecurityLayer.tls_required` is true, even if the server does not advertise a STARTTLS stream feature. This might help to prevent trivial downgrade attacks, and we don’t have anything to lose at this point anymore anyways. :attr:`~.security_layer.SecurityLayer.ssl_context_factory` and :attr:`~.security_layer.SecurityLayer.certificate_verifier_factory` are used to configure the TLS connection. .. versionchanged:: 0.10 The `negotiation_timeout` is set as :attr:`~.XMLStream.deadtime_hard_limit` on the returned XML stream. """ features_future = asyncio.Future(loop=loop) stream = protocol.XMLStream( to=domain, features_future=features_future, base_logger=base_logger, ) if base_logger is not None: logger = base_logger.getChild(type(self).__name__) else: logger = logging.getLogger(".".join([ __name__, type(self).__qualname__, ])) try: transport, _ = await ssl_transport.create_starttls_connection( loop, lambda: stream, host=host, port=port, peer_hostname=host, server_hostname=to_ascii(domain), use_starttls=True, ) except: # NOQA stream.abort() raise stream.deadtime_hard_limit = timedelta(seconds=negotiation_timeout) features = await features_future try: features[nonza.StartTLSFeature] except KeyError: if not metadata.tls_required: return transport, stream, await features_future logger.debug( "attempting STARTTLS despite not announced since it is" " required") try: response = await protocol.send_and_wait_for( stream, [ nonza.StartTLS(), ], [ nonza.StartTLSFailure, nonza.StartTLSProceed, ] ) except errors.StreamError: raise errors.TLSUnavailable( "STARTTLS not supported by server, but required by client" ) if not isinstance(response, nonza.StartTLSProceed): if metadata.tls_required: message = ( "server failed to STARTTLS" ) protocol.send_stream_error_and_close( stream, condition=errors.StreamErrorCondition.POLICY_VIOLATION, text=message, ) raise errors.TLSUnavailable(message) return transport, stream, await features_future verifier = metadata.certificate_verifier_factory() await verifier.pre_handshake( domain, host, port, metadata, ) ssl_context = metadata.ssl_context_factory() verifier.setup_context(ssl_context, transport) await stream.starttls( ssl_context=ssl_context, post_handshake_callback=verifier.post_handshake, ) features = await protocol.reset_stream_and_get_features( stream, timeout=negotiation_timeout, ) return transport, stream, features class XMPPOverTLSConnector(BaseConnector): """ Establish an XML stream using XMPP-over-TLS, as per :xep:`368`. .. automethod:: connect """ @property def dane_supported(self): return False @property def tls_supported(self): return True def _context_factory_factory(self, logger, metadata, verifier): def context_factory(transport): ssl_context = metadata.ssl_context_factory() if hasattr(ssl_context, "set_alpn_protos"): try: ssl_context.set_alpn_protos([b'xmpp-client']) except NotImplementedError: logger.warning( "the underlying OpenSSL library does not support ALPN" ) else: logger.warning( "OpenSSL.SSL.Context lacks set_alpn_protos - " "please update pyOpenSSL to a recent version" ) verifier.setup_context(ssl_context, transport) return ssl_context return context_factory async def connect(self, loop, metadata, domain, host, port, negotiation_timeout, base_logger=None): """ .. seealso:: :meth:`BaseConnector.connect` For general information on the :meth:`connect` method. Connect to `host` at TCP port number `port`. The :class:`aioxmpp.security_layer.SecurityLayer` object `metadata` is used to determine the parameters of the TLS connection. The connector connects to the server by directly establishing TLS; no XML stream is started before TLS negotiation, in accordance to :xep:`368` and how legacy SSL was handled in the past. :attr:`~.security_layer.SecurityLayer.ssl_context_factory` and :attr:`~.security_layer.SecurityLayer.certificate_verifier_factory` are used to configure the TLS connection. .. versionchanged:: 0.10 The `negotiation_timeout` is set as :attr:`~.XMLStream.deadtime_hard_limit` on the returned XML stream. """ features_future = asyncio.Future(loop=loop) stream = protocol.XMLStream( to=domain, features_future=features_future, base_logger=base_logger, ) if base_logger is not None: logger = base_logger.getChild(type(self).__name__) else: logger = logging.getLogger(".".join([ __name__, type(self).__qualname__, ])) verifier = metadata.certificate_verifier_factory() await verifier.pre_handshake( domain, host, port, metadata, ) context_factory = self._context_factory_factory(logger, metadata, verifier) try: transport, _ = await ssl_transport.create_starttls_connection( loop, lambda: stream, host=host, port=port, peer_hostname=host, server_hostname=to_ascii(domain), post_handshake_callback=verifier.post_handshake, ssl_context_factory=context_factory, use_starttls=False, ) except: # NOQA stream.abort() raise stream.deadtime_hard_limit = timedelta(seconds=negotiation_timeout) return transport, stream, await features_future aioxmpp/custom_queue.py000066400000000000000000000042441416014621300156250ustar00rootroot00000000000000######################################################################## # File name: custom_queue.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio import collections class AsyncDeque: def __init__(self, *, loop=None): super().__init__() self._loop = loop self._data = collections.deque() self._non_empty = asyncio.Event() self._non_empty.clear() def __len__(self): return len(self._data) def __contains__(self, obj): return obj in self._data def empty(self): return not self._non_empty.is_set() def put_nowait(self, obj): self._data.append(obj) self._non_empty.set() def putleft_nowait(self, obj): self._data.appendleft(obj) self._non_empty.set() def get_nowait(self): try: item = self._data.popleft() except IndexError: raise asyncio.QueueEmpty() from None if not self._data: self._non_empty.clear() return item def getright_nowait(self): try: item = self._data.pop() except IndexError: raise asyncio.QueueEmpty() from None if not self._data: self._non_empty.clear() return item async def get(self): while not self._data: await self._non_empty.wait() return self.get_nowait() def clear(self): self._data.clear() self._non_empty.clear() aioxmpp/disco/000077500000000000000000000000001416014621300136325ustar00rootroot00000000000000aioxmpp/disco/__init__.py000066400000000000000000000064061416014621300157510ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.disco` --- Service discovery support (:xep:`0030`) ################################################################# This module provides support for :xep:`Service Discovery <30>`. For this, it provides a :class:`~aioxmpp.service.Service` subclass which can be loaded into a client using :meth:`.Client.summon`. Services ======== The following services are provided by this subpackage and available directly from :mod:`aioxmpp`: .. currentmodule:: aioxmpp .. autosummary:: :nosignatures: DiscoServer DiscoClient .. versionchanged:: 0.8 Prior to version 0.8, both services were provided by a single class (:class:`aioxmpp.disco.Service`). This is not the case anymore, and there is no replacement. If you need to write backwards compatible code, you could be doing something like this:: try: aioxmpp.DiscoServer except AttributeError: aioxmpp.DiscoServer = aioxmpp.disco.Service aioxmpp.DiscoClient = aioxmpp.disco.Service This should work, because the old :class:`Service` class provided the features of both of the individual classes. The detailed documentation of the classes follows: .. autoclass:: DiscoServer .. autoclass:: DiscoClient .. currentmodule:: aioxmpp.disco Entity information ------------------ .. autoclass:: Node .. autoclass:: StaticNode .. autoclass:: mount_as_node .. autoclass:: register_feature .. autoclass:: RegisteredFeature .. module:: aioxmpp.disco.xso .. currentmodule:: aioxmpp.disco.xso :mod:`.disco.xso` --- IQ payloads ================================= The submodule :mod:`aioxmpp.disco.xso` contains the :class:`~aioxmpp.xso.XSO` classes which describe the IQ payloads used by this subpackage. You will encounter some of these in return values, but there should never be a need to construct them by yourself; the :class:`~aioxmpp.disco.Service` handles it all. Information queries ------------------- .. autoclass:: InfoQuery(*[, identities][, features][, node]) .. autoclass:: Feature(*[, var]) .. autoclass:: Identity(*[, category][, type_][, name][, lang]) Item queries ------------ .. autoclass:: ItemsQuery(*[, node][, items]) .. autoclass:: Item(*[, jid][, name][, node]) .. currentmodule:: aioxmpp.disco """ from . import xso # NOQA: F401 from .service import (DiscoClient, DiscoServer, Node, StaticNode, # NOQA: F401 mount_as_node, register_feature, RegisteredFeature) aioxmpp/disco/service.py000066400000000000000000001015151416014621300156470ustar00rootroot00000000000000######################################################################## # File name: service.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio import contextlib import functools import itertools import aioxmpp.cache import aioxmpp.callbacks import aioxmpp.errors as errors import aioxmpp.service as service import aioxmpp.structs as structs import aioxmpp.stanza as stanza from aioxmpp.utils import namespaces from . import xso as disco_xso class Node(object): """ A :class:`Node` holds the information related to a specific node within the entity referred to by a JID, with respect to :xep:`30` semantics. A :class:`Node` always has at least one identity (or it will return as ````). It may have zero or more features beyond the :xep:`30` features which are statically included. To manage the identities and the features of a node, use the following methods: .. automethod:: register_feature .. automethod:: unregister_feature .. automethod:: register_identity .. automethod:: set_identity_names .. automethod:: unregister_identity To access the declared features and identities, use: .. automethod:: iter_features .. automethod:: iter_identities .. automethod:: as_info_xso To access items, use: .. automethod:: iter_items Signals provide information about changes: .. signal:: on_info_changed() This signal emits when a feature or identity is registered or unregistered. As mentioned, bare :class:`Node` objects have no items; there are subclasses of :class:`Node` which support items: ====================== ================================================== :class:`StaticNode` Support for a list of :class:`.xso.Item` instances :class:`.DiscoServer` Support for "mountpoints" for node subtrees ====================== ================================================== """ STATIC_FEATURES = frozenset({namespaces.xep0030_info}) on_info_changed = aioxmpp.callbacks.Signal() def __init__(self): super().__init__() self._identities = {} self._features = set() def iter_identities(self, stanza=None): """ Return an iterator of tuples describing the identities of the node. :param stanza: The IQ request stanza :type stanza: :class:`~aioxmpp.IQ` or :data:`None` :rtype: iterable of (:class:`str`, :class:`str`, :class:`str` or :data:`None`, :class:`str` or :data:`None`) tuples :return: :xep:`30` identities of this node `stanza` can be the :class:`aioxmpp.IQ` stanza of the request. This can be used to hide a node depending on who is asking. If the returned iterable is empty, the :class:`~.DiscoServer` returns an ```` error. `stanza` may be :data:`None` if the identities are queried without a specific request context. In that case, implementors should assume that the result is visible to everybody. .. note:: Subclasses must allow :data:`None` for `stanza` and default it to :data:`None`. Return an iterator which yields tuples consisting of the category, the type, the language code and the name of each identity declared in this :class:`Node`. Both the language code and the name may be :data:`None`, if no names or a name without language code have been declared. """ for (category, type_), names in self._identities.items(): for lang, name in names.items(): yield category, type_, lang, name if not names: yield category, type_, None, None def iter_features(self, stanza=None): """ Return an iterator which yields the features of the node. :param stanza: The IQ request stanza :type stanza: :class:`~aioxmpp.IQ` :rtype: iterable of :class:`str` :return: :xep:`30` features of this node `stanza` is the :class:`aioxmpp.IQ` stanza of the request. This can be used to filter the list according to who is asking (not recommended). `stanza` may be :data:`None` if the features are queried without a specific request context. In that case, implementors should assume that the result is visible to everybody. .. note:: Subclasses must allow :data:`None` for `stanza` and default it to :data:`None`. The features are returned as strings. The features demanded by :xep:`30` are always returned. """ return itertools.chain( iter(self.STATIC_FEATURES), iter(self._features) ) def iter_items(self, stanza=None): """ Return an iterator which yields the items of the node. :param stanza: The IQ request stanza :type stanza: :class:`~aioxmpp.IQ` :rtype: iterable of :class:`~.disco.xso.Item` :return: Items of the node `stanza` is the :class:`aioxmpp.IQ` stanza of the request. This can be used to localize the list to the language of the stanza or filter it according to who is asking. `stanza` may be :data:`None` if the items are queried without a specific request context. In that case, implementors should assume that the result is visible to everybody. .. note:: Subclasses must allow :data:`None` for `stanza` and default it to :data:`None`. A bare :class:`Node` cannot hold any items and will thus return an iterator which does not yield any element. """ return iter([]) def register_feature(self, var): """ Register a feature with the namespace variable `var`. If the feature is already registered or part of the default :xep:`30` features, a :class:`ValueError` is raised. """ if var in self._features or var in self.STATIC_FEATURES: raise ValueError("feature already claimed: {!r}".format(var)) self._features.add(var) self.on_info_changed() def register_identity(self, category, type_, *, names={}): """ Register an identity with the given `category` and `type_`. If there is already a registered identity with the same `category` and `type_`, :class:`ValueError` is raised. `names` may be a mapping which maps :class:`.structs.LanguageTag` instances to strings. This mapping will be used to produce ```` declarations with the respective ``xml:lang`` and ``name`` attributes. """ key = category, type_ if key in self._identities: raise ValueError("identity already claimed: {!r}".format(key)) self._identities[key] = names self.on_info_changed() def set_identity_names(self, category, type_, names={}): """ Update the names of an identity. :param category: The category of the identity to update. :type category: :class:`str` :param type_: The type of the identity to update. :type type_: :class:`str` :param names: The new internationalised names to set for the identity. :type names: :class:`~.abc.Mapping` from :class:`.structs.LanguageTag` to :class:`str` :raises ValueError: if no identity with the given category and type is currently registered. """ key = category, type_ if key not in self._identities: raise ValueError("identity not registered: {!r}".format(key)) self._identities[key] = names self.on_info_changed() def unregister_feature(self, var): """ Unregister a feature which has previously been registered using :meth:`register_feature`. If the feature has not been registered previously, :class:`KeyError` is raised. .. note:: The features which are mandatory per :xep:`30` are always registered and cannot be unregistered. For the purpose of unregistration, they behave as if they had never been registered; for the purpose of registration, they behave as if they had been registered before. """ self._features.remove(var) self.on_info_changed() def unregister_identity(self, category, type_): """ Unregister an identity previously registered using :meth:`register_identity`. If no identity with the given `category` and `type_` has been registered before, :class:`KeyError` is raised. If the identity to remove is the last identity of the :class:`Node`, :class:`ValueError` is raised; a node must always have at least one identity. """ key = category, type_ if key not in self._identities: raise KeyError(key) if len(self._identities) == 1: raise ValueError("cannot remove last identity") del self._identities[key] self.on_info_changed() def as_info_xso(self, stanza=None): """ Construct a :class:`~.disco.xso.InfoQuery` response object for this node. :param stanza: The IQ request stanza :type stanza: :class:`~aioxmpp.IQ` :rtype: iterable of :class:`~.disco.xso.InfoQuery` :return: The disco#info response for this node. The resulting :class:`~.disco.xso.InfoQuery` carries the features and identities as returned by :meth:`iter_features` and :meth:`iter_identities`. The :attr:`~.disco.xso.InfoQuery.node` attribute is at its default value and may need to be set by the caller accordingly. `stanza` is passed to :meth:`iter_features` and :meth:`iter_identities`. See those methods for information on the effects. .. versionadded:: 0.9 """ result = disco_xso.InfoQuery() result.features.update(self.iter_features(stanza)) result.identities[:] = ( disco_xso.Identity( category=category, type_=type_, lang=lang, name=name, ) for category, type_, lang, name in self.iter_identities(stanza) ) return result class StaticNode(Node): """ A :class:`StaticNode` is a :class:`Node` with a non-dynamic set of items. .. attribute:: items A list of :class:`.xso.Item` instances. These items will be returned when the node is queried for it’s :xep:`30` items. It is the responsibility of the user to ensure that the set of items is valid. This includes avoiding duplicate items. .. automethod:: clone """ def __init__(self): super().__init__() self.items = [] def iter_items(self, stanza=None): return iter(self.items) @classmethod def clone(cls, other_node): """ Clone another :class:`Node` and return as :class:`StaticNode`. :param other_node: The node which shall be cloned :type other_node: :class:`Node` :rtype: :class:`StaticNode` :return: A static node which has the exact same features, identities and items as `other_node`. The features and identities are copied over into the resulting :class:`StaticNode`. The items of `other_node` are not copied but merely referenced, so changes to the item *objects* of `other_node` will be reflected in the result. .. versionadded:: 0.9 """ result = cls() result._features = { feature for feature in other_node.iter_features() if feature not in cls.STATIC_FEATURES } for category, type_, lang, name in other_node.iter_identities(): names = result._identities.setdefault( (category, type_), aioxmpp.structs.LanguageMap() ) names[lang] = name result.items = list(other_node.iter_items()) return result class DiscoServer(service.Service, Node): """ Answer Service Discovery (:xep:`30`) requests sent to this client. This service implements handlers for ``…disco#info`` and ``…disco#items`` IQ requests. It provides methods to configure the contents of these responses. .. seealso:: :class:`DiscoClient` for a service which provides methods to query Service Discovery information from other entities. The :class:`DiscoServer` inherits from :class:`~.disco.Node` to manage the identities and features of the client. The identities and features declared in the service using the :class:`~.disco.Node` interface on the :class:`DiscoServer` instance are returned when a query is received for the JID with an empty or unset ``node`` attribute. For completeness, the relevant methods are listed here. Refer to the :class:`~.disco.Node` documentation for details. .. autosummary:: .disco.Node.register_feature .disco.Node.unregister_feature .disco.Node.register_identity .disco.Node.unregister_identity .. note:: Upon construction, the :class:`DiscoServer` adds a default identity with category ``"client"`` and type ``"bot"`` to the root :class:`~.disco.Node`. This is to comply with :xep:`30`, which specifies that at least one identity must always be returned. Otherwise, the service would be forced to send a malformed response or reply with ````. After having added another identity, that default identity can be removed. Other :class:`~.disco.Node` instances can be registered with the service using the following methods: .. automethod:: mount_node .. automethod:: unmount_node """ on_info_result = aioxmpp.callbacks.Signal() def __init__(self, client, **kwargs): super().__init__(client, **kwargs) self._node_mounts = { None: self } self.register_identity( "client", "bot", names={ structs.LanguageTag.fromstr("en"): "aioxmpp default identity" } ) @aioxmpp.service.iq_handler( aioxmpp.structs.IQType.GET, disco_xso.InfoQuery) async def handle_info_request(self, iq): request = iq.payload try: node = self._node_mounts[request.node] except KeyError: raise errors.XMPPModifyError( condition=errors.ErrorCondition.ITEM_NOT_FOUND ) response = node.as_info_xso(iq) response.node = request.node if not response.identities: raise errors.XMPPModifyError( condition=errors.ErrorCondition.ITEM_NOT_FOUND, ) return response @aioxmpp.service.iq_handler( aioxmpp.structs.IQType.GET, disco_xso.ItemsQuery) async def handle_items_request(self, iq): request = iq.payload try: node = self._node_mounts[request.node] except KeyError: raise errors.XMPPModifyError( condition=errors.ErrorCondition.ITEM_NOT_FOUND ) response = disco_xso.ItemsQuery() response.items.extend(node.iter_items(iq)) return response def mount_node(self, mountpoint, node): """ Mount the :class:`Node` `node` to be returned when a peer requests :xep:`30` information for the node `mountpoint`. """ self._node_mounts[mountpoint] = node def unmount_node(self, mountpoint): """ Unmount the node mounted at `mountpoint`. .. seealso:: :meth:`mount_node` for a way for mounting :class:`~.disco.Node` instances. """ del self._node_mounts[mountpoint] class DiscoClient(service.Service): """ Provide cache-backed Service Discovery (:xep:`30`) queries. This service provides methods to query Service Discovery information from other entities in the XMPP network. The results are cached transparently. .. seealso:: :class:`.DiscoServer` for a service which answers Service Discovery queries sent to the client by other entities. :class:`.EntityCapsService` for a service which uses :xep:`115` to fill the cache of the :class:`DiscoClient` with offline information. Querying other entities’ service discovery information: .. automethod:: query_info .. automethod:: query_items To prime the cache with information, the following methods can be used: .. automethod:: set_info_cache .. automethod:: set_info_future To control the size of caches, the following properties are available: .. autoattribute:: info_cache_size :annotation: = 10000 .. autoattribute:: items_cache_size :annotation: = 100 .. automethod:: flush_cache Usage example, assuming that you have a :class:`.node.Client` `client`:: import aioxmpp.disco as disco # load service into node sd = client.summon(aioxmpp.DiscoClient) # retrieve server information server_info = yield from sd.query_info( node.local_jid.replace(localpart=None, resource=None) ) # retrieve resources resources = yield from sd.query_items( node.local_jid.bare() ) """ on_info_result = aioxmpp.callbacks.Signal() def __init__(self, client, **kwargs): super().__init__(client, **kwargs) self._info_pending = aioxmpp.cache.LRUDict() self._info_pending.maxsize = 10000 self._items_pending = aioxmpp.cache.LRUDict() self._items_pending.maxsize = 100 self.client.on_stream_destroyed.connect( self._clear_cache ) @property def info_cache_size(self): """ Maximum number of cache entries in the cache for :meth:`query_info`. This is mostly a measure to prevent malicious peers from exhausting memory by spamming :mod:`aioxmpp.entitycaps` capability hashes. .. versionadded:: 0.9 """ return self._info_pending.maxsize @info_cache_size.setter def info_cache_size(self, value): self._info_pending.maxsize = value @property def items_cache_size(self): """ Maximum number of cache entries in the cache for :meth:`query_items`. .. versionadded:: 0.9 """ return self._items_pending.maxsize @items_cache_size.setter def items_cache_size(self, value): self._items_pending.maxsize = value def _clear_cache(self): for fut in self._info_pending.values(): if not fut.done(): fut.cancel() self._info_pending.clear() for fut in self._items_pending.values(): if not fut.done(): fut.cancel() self._items_pending.clear() def _handle_info_received(self, jid, node, task): try: result = task.result() except Exception: return self.on_info_result(jid, node, result) def flush_cache(self): """ Clear the cache. This clears the internal cache in a way which lets existing queries continue, but the next query for each target will behave as if `require_fresh` had been set to true. """ self._info_pending.clear() self._items_pending.clear() async def send_and_decode_info_query(self, jid, node): request_iq = stanza.IQ(to=jid, type_=structs.IQType.GET) request_iq.payload = disco_xso.InfoQuery(node=node) response = await self.client.send(request_iq) return response async def query_info(self, jid, *, node=None, require_fresh=False, timeout=None, no_cache=False): """ Query the features and identities of the specified entity. :param jid: The entity to query. :type jid: :class:`aioxmpp.JID` :param node: The node to query. :type node: :class:`str` or :data:`None` :param require_fresh: Boolean flag to discard previous caches. :type require_fresh: :class:`bool` :param timeout: Optional timeout for the response. :type timeout: :class:`float` :param no_cache: Boolean flag to forbid caching of the request. :type no_cache: :class:`bool` :rtype: :class:`.xso.InfoQuery` :return: Service discovery information of the `node` at `jid`. The requests are cached. This means that only one request is ever fired for a given target (identified by the `jid` and the `node`). The request is re-used for all subsequent requests to that identity. If `require_fresh` is set to true, the above does not hold and a fresh request is always created. The new request is the request which will be used as alias for subsequent requests to the same identity. The visible effects of this are twofold: * Caching: Results of requests are implicitly cached * Aliasing: Two concurrent requests will be aliased to one request to save computing resources Both can be turned off by using `require_fresh`. In general, you should not need to use `require_fresh`, as all requests are implicitly cancelled whenever the underlying session gets destroyed. `no_cache` can be set to true to prevent future requests to be aliased to this request, i.e. the request is not stored in the internal request cache. This does not affect `require_fresh`, i.e. if a cached result is available, it is used. The `timeout` can be used to restrict the time to wait for a response. If the timeout triggers, :class:`TimeoutError` is raised. If :meth:`~.Client.send` raises an exception, all queries which were running simultaneously for the same target re-raise that exception. The result is not cached though. If a new query is sent at a later point for the same target, a new query is actually sent, independent of the value chosen for `require_fresh`. .. versionchanged:: 0.9 The `no_cache` argument was added. """ key = jid, node if not require_fresh: try: request = self._info_pending[key] except KeyError: pass else: try: return await request except asyncio.CancelledError: pass request = asyncio.ensure_future( self.send_and_decode_info_query(jid, node) ) request.add_done_callback( functools.partial( self._handle_info_received, jid, node ) ) if not no_cache: self._info_pending[key] = request try: if timeout is not None: try: result = await asyncio.wait_for( request, timeout=timeout) except asyncio.TimeoutError: raise TimeoutError() else: result = await request except: # NOQA if request.done(): try: pending = self._info_pending[key] except KeyError: pass else: if pending is request: del self._info_pending[key] raise return result async def query_items(self, jid, *, node=None, require_fresh=False, timeout=None): """ Query the items of the specified entity. :param jid: The entity to query. :type jid: :class:`aioxmpp.JID` :param node: The node to query. :type node: :class:`str` or :data:`None` :param require_fresh: Boolean flag to discard previous caches. :type require_fresh: :class:`bool` :param timeout: Optional timeout for the response. :type timeout: :class:`float` :rtype: :class:`.xso.ItemsQuery` :return: Service discovery items of the `node` at `jid`. The arguments have the same semantics as with :meth:`query_info`, as does the caching and error handling. """ key = jid, node if not require_fresh: try: request = self._items_pending[key] except KeyError: pass else: try: return await request except asyncio.CancelledError: pass request_iq = stanza.IQ(to=jid, type_=structs.IQType.GET) request_iq.payload = disco_xso.ItemsQuery(node=node) request = asyncio.ensure_future( self.client.send(request_iq) ) self._items_pending[key] = request try: if timeout is not None: try: result = await asyncio.wait_for( request, timeout=timeout) except asyncio.TimeoutError: raise TimeoutError() else: result = await request except: # NOQA if request.done(): try: pending = self._items_pending[key] except KeyError: pass else: if pending is request: del self._items_pending[key] raise return result def set_info_cache(self, jid, node, info): """ This is a wrapper around :meth:`set_info_future` which creates a future and immediately assigns `info` as its result. .. versionadded:: 0.5 """ fut = asyncio.Future() fut.set_result(info) self.set_info_future(jid, node, fut) def set_info_future(self, jid, node, fut): """ Override the cache entry (if one exists) for :meth:`query_info` of the `jid` and `node` combination with the given :class:`asyncio.Future` fut. The future must receive a :class:`dict` compatible to the output of :meth:`.xso.InfoQuery.to_dict`. As usual, the cache can be bypassed and cleared by passing `require_fresh` to :meth:`query_info`. .. seealso:: Module :mod:`aioxmpp.entitycaps` :xep:`0115` implementation which uses this method to prime the cache with information derived from Entity Capability announcements. .. note:: If a future is set to exception state, it will still remain and make all queries for that target fail with that exception, until a query uses `require_fresh`. .. versionadded:: 0.5 """ self._info_pending[jid, node] = fut class mount_as_node(service.Descriptor): """ Service descriptor which mounts the :class:`~.service.Service` as :class:`.DiscoServer` node. :param mountpoint: The mountpoint at which to mount the node. :type mountpoint: :class:`str` .. versionadded:: 0.8 When the service is instaniated, it is mounted as :class:`~.disco.Node` at the given `mountpoint`; it must thus also inherit from :class:`~.disco.Node` or implement a compatible interface. .. autoattribute:: mountpoint """ def __init__(self, mountpoint): super().__init__() self._mountpoint = mountpoint @property def mountpoint(self): """ The mountpoint at which the node is mounted. """ return self._mountpoint @property def required_dependencies(self): return [DiscoServer] @contextlib.contextmanager def init_cm(self, instance): disco = instance.dependencies[DiscoServer] disco.mount_node(self._mountpoint, instance) try: yield finally: disco.unmount_node(self._mountpoint) @property def value_type(self): return type(None) class RegisteredFeature: """ Manage registration of a feature with a :class:`DiscoServer`. :param service: The service implementing the service discovery server. :type service: :class:`DiscoServer` :param feature: The feature to register. :type feature: :class:`str` .. note:: Normally, you would not create an instance of this object manually. Use the :class:`register_feature` descriptor on your :class:`aioxmpp.Service` which will provide a :class:`RegisteredFeature` object:: class Foo(aioxmpp.Service): _some_feature = aioxmpp.disco.register_feature( "urn:of:the:feature" ) # after __init__, self._some_feature is a RegisteredFeature # instance. @property def some_feature_enabled(self): # better do not expose the enabled boolean directly; this # gives you the opportunity to do additional things when it # is changed, such as disabling multiple features at once. return self._some_feature.enabled @some_feature_enabled.setter def some_feature_enabled(self, value): self._some_feature.enabled = value .. versionadded:: 0.9 This object can be used as a context manager. Upon entering the context, the feature is registered. When the context is left, the feature is unregistered. .. note:: The context-manager use does not nest sensibly. Thus, do not use th context-manager feature on :class:`RegisteredFeature` instances which are created by :class:`register_feature`, as :class:`register_feature` uses the context manager to register/unregister the feature on initialisation/shutdown. Independently, it is possible to control the registration status of the feature using :attr:`enabled`. .. autoattribute:: enabled .. autoattribute:: feature """ def __init__(self, service, feature): self.__service = service self.__feature = feature self.__enabled = False @property def enabled(self): """ Boolean indicating whether the feature is registered by this object or not. When this attribute is changed to :data:`True`, the feature is registered. When the attribute is changed to :data:`False`, the feature is unregistered. """ return self.__enabled @enabled.setter def enabled(self, value): value = bool(value) if value == self.__enabled: return if value: self.__service.register_feature(self.__feature) else: self.__service.unregister_feature(self.__feature) self.__enabled = value @property def feature(self): """ The feature this object is controlling (read-only). """ return self.__feature def __enter__(self): self.enabled = True return self def __exit__(self, exc_type, exc_value, tb): self.enabled = False class register_feature(service.Descriptor): """ Service descriptor which registers a service discovery feature. :param feature: The feature to register. :type feature: :class:`str` .. versionadded:: 0.8 When the service is instaniated, the `feature` is registered at the :class:`~.DiscoServer`. On instances, the attribute which is described with this is a :class:`RegisteredFeature` instance. .. versionchanged:: 0.9 :class:`RegisteredFeature` was added; before, the attribute reads as :data:`None`. """ def __init__(self, feature): super().__init__() self._feature = feature @property def feature(self): """ The feature which is registered. """ return self._feature @property def required_dependencies(self): return [DiscoServer] def init_cm(self, instance): disco = instance.dependencies[DiscoServer] return RegisteredFeature(disco, self._feature) @property def value_type(self): return RegisteredFeature aioxmpp/disco/xso.py000066400000000000000000000204671416014621300150260ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000365231416014621300152420ustar00rootroot00000000000000######################################################################## # File name: dispatcher.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.dispatcher` --- Dispatch stanzas to callbacks ############################################################ .. versionadded:: 0.9 The whole module was added in 0.9. Stanza Dispatchers for Messages and Presences ============================================= .. autoclass:: SimpleMessageDispatcher .. autoclass:: SimplePresenceDispatcher Decorators for :class:`aioxmpp.service.Service` Methods ======================================================= .. autodecorator:: message_handler .. autodecorator:: presence_handler Test Functions -------------- .. autofunction:: is_message_handler .. autofunction:: is_presence_handler Base Class for Stanza Dispatchers ================================= .. autoclass:: SimpleStanzaDispatcher """ import abc import asyncio import contextlib import aioxmpp.service import aioxmpp.stream class SimpleStanzaDispatcher(metaclass=abc.ABCMeta): """ Dispatch stanzas based on their sender and type. This is a service base class (not a service you should summon) which can be used to implement simple, pre-0.9 presence and message dispatching. For users, the following methods are relevant: .. automethod:: register_callback .. automethod:: unregister_callback .. automethod:: handler_context For deriving classes, the following methods are relevant: .. automethod:: _feed Subclasses must also provide the following property: .. autoattribute:: local_jid """ def __init__(self, **kwargs): super().__init__(**kwargs) self._map = {} @abc.abstractproperty def local_jid(self): """ The bare JID of the client for which this dispatcher is used. This is required to map missing ``@from`` attributes to this JID. The attribute must be provided by implementing subclasses. """ def _feed(self, stanza): """ Dispatch the given `stanza`. :param stanza: Stanza to dispatch :type stanza: :class:`~.StanzaBase` :rtype: :class:`bool` :return: true if the stanza was dispatched, false otherwise. Dispatch the stanza to up to one handler registered on the dispatcher. If no handler is found for the stanza, :data:`False` is returned. Otherwise, :data:`True` is returned. """ from_ = stanza.from_ if from_ is None: from_ = self.local_jid keys = [ (stanza.type_, from_, False), (stanza.type_, from_.bare(), True), (None, from_, False), (None, from_.bare(), True), (stanza.type_, None, False), (None, from_, False), (None, None, False), ] for key in keys: try: cb = self._map[key] except KeyError: continue cb(stanza) return def register_callback(self, type_, from_, cb, *, wildcard_resource=True): """ Register a callback function. :param type_: Stanza type to listen for, or :data:`None` for a wildcard match. :param from_: Sender to listen for, or :data:`None` for a full wildcard match. :type from_: :class:`aioxmpp.JID` or :data:`None` :param cb: Callback function to register :param wildcard_resource: Whether to wildcard the resourcepart of the JID. :type wildcard_resource: :class:`bool` :raises ValueError: if another function is already registered for the callback slot. `cb` will be called whenever a stanza with the matching `type_` and `from_` is processed. The following wildcarding rules apply: 1. If the :attr:`~aioxmpp.stanza.StanzaBase.from_` attribute of the stanza has a resourcepart, the following lookup order for callbacks is used: +---------------------------+----------------------------------+----------------------+ |``type_`` |``from_`` |``wildcard_resource`` | +===========================+==================================+======================+ |:attr:`~.StanzaBase.type_` |:attr:`~.StanzaBase.from_` |*any* | +---------------------------+----------------------------------+----------------------+ |:attr:`~.StanzaBase.type_` |*bare* :attr:`~.StanzaBase.from_` |:data:`True` | +---------------------------+----------------------------------+----------------------+ |:data:`None` |:attr:`~.StanzaBase.from_` |*any* | +---------------------------+----------------------------------+----------------------+ |:data:`None` |*bare* :attr:`~.StanzaBase.from_` |:data:`True` | +---------------------------+----------------------------------+----------------------+ |:attr:`~.StanzaBase.type_` |:data:`None` |*any* | +---------------------------+----------------------------------+----------------------+ |:data:`None` |:data:`None` |*any* | +---------------------------+----------------------------------+----------------------+ 2. If the :attr:`~aioxmpp.stanza.StanzaBase.from_` attribute of the stanza does *not* have a resourcepart, the following lookup order for callbacks is used: +---------------------------+---------------------------+----------------------+ |``type_`` |``from_`` |``wildcard_resource`` | +===========================+===========================+======================+ |:attr:`~.StanzaBase.type_` |:attr:`~.StanzaBase.from_` |:data:`False` | +---------------------------+---------------------------+----------------------+ |:data:`None` |:attr:`~.StanzaBase.from_` |:data:`False` | +---------------------------+---------------------------+----------------------+ |:attr:`~.StanzaBase.type_` |:data:`None` |*any* | +---------------------------+---------------------------+----------------------+ |:data:`None` |:data:`None` |*any* | +---------------------------+---------------------------+----------------------+ Only the first callback which matches is called. `wildcard_resource` is ignored if `from_` is a full JID or :data:`None`. .. note:: When the server sends a stanza without from attribute, it is replaced with the bare :attr:`local_jid`, as per :rfc:`6120`. """ # NOQA: E501 if from_ is None or not from_.is_bare: wildcard_resource = False key = (type_, from_, wildcard_resource) if key in self._map: raise ValueError( "only one listener allowed per matcher" ) self._map[type_, from_, wildcard_resource] = cb def unregister_callback(self, type_, from_, *, wildcard_resource=True): """ Unregister a callback function. :param type_: Stanza type to listen for, or :data:`None` for a wildcard match. :param from_: Sender to listen for, or :data:`None` for a full wildcard match. :type from_: :class:`aioxmpp.JID` or :data:`None` :param wildcard_resource: Whether to wildcard the resourcepart of the JID. :type wildcard_resource: :class:`bool` The callback must be disconnected with the same arguments as were used to connect it. """ if from_ is None or not from_.is_bare: wildcard_resource = False self._map.pop((type_, from_, wildcard_resource)) @contextlib.contextmanager def handler_context(self, type_, from_, cb, *, wildcard_resource=True): """ Context manager which temporarily registers a callback. The arguments are the same as for :meth:`register_callback`. When the context is entered, the callback `cb` is registered. When the context is exited, no matter if an exception is raised or not, the callback is unregistered. """ self.register_callback( type_, from_, cb, wildcard_resource=wildcard_resource ) try: yield finally: self.unregister_callback( type_, from_, wildcard_resource=wildcard_resource ) class SimpleMessageDispatcher(aioxmpp.service.Service, SimpleStanzaDispatcher): """ Dispatch messages to callbacks. This :class:`~aioxmpp.service.Service` dispatches :class:`~aioxmpp.Message` stanzas to callbacks. Callbacks registrations are managed with the :meth:`.SimpleStanzaDispatcher.register_callback` and :meth:`.SimpleStanzaDispatcher.unregister_callback` methods of the base class. The `type_` argument to these methods must be a :class:`aioxmpp.MessageType` or :data:`None` to make any sense. .. note:: It is not recommended to mix the use of a :class:`SimpleMessageDispatcher` with the modern Instant Messaging features provided by the :mod:`aioxmpp.im` module. Both will receive the messages and this may thus lead to duplicate messages. """ @property def local_jid(self): return self.client.local_jid @aioxmpp.service.depsignal(aioxmpp.stream.StanzaStream, "on_message_received") def _feed(self, stanza): super()._feed(stanza) class SimplePresenceDispatcher(aioxmpp.service.Service, SimpleStanzaDispatcher): """ Dispatch presences to callbacks. This :class:`~aioxmpp.service.Service` dispatches :class:`~aioxmpp.Presence` stanzas to callbacks. Callbacks registrations are managed with the :meth:`.SimpleStanzaDispatcher.register_callback` and :meth:`.SimpleStanzaDispatcher.unregister_callback` methods of the base class. The `type_` argument to these methods must be a :class:`aioxmpp.MessageType` or :data:`None` to make any sense. .. warning:: It is not recommended to mix the use of a :class:`SimplePresenceDispatcher` with :class:`aioxmpp.RosterClient` and :class:`aioxmpp.PresenceClient`. Both of these register callbacks at the :class:`SimplePresenceDispatcher`. Registering callbacks for different slots will either make those callbacks not be called at all or will make the services miss stanzas. """ @property def local_jid(self): return self.client.local_jid @aioxmpp.service.depsignal(aioxmpp.stream.StanzaStream, "on_presence_received") def _feed(self, stanza): super()._feed(stanza) def _apply_message_handler(instance, stream, func, type_, from_): return instance.dependencies[SimpleMessageDispatcher].handler_context( type_, from_, func, ) def _apply_presence_handler(instance, stream, func, type_, from_): return instance.dependencies[SimplePresenceDispatcher].handler_context( type_, from_, func, ) def message_handler(type_, from_): """ Register the decorated function as message handler. :param type_: Message type to listen for :type type_: :class:`~.MessageType` :param from_: Sender JIDs to listen for :type from_: :class:`aioxmpp.JID` or :data:`None` :raise TypeError: if the decorated object is a coroutine function .. seealso:: :meth:`~.StanzaStream.register_message_callback` for more details on the `type_` and `from_` arguments .. versionchanged:: 0.9 This is now based on :class:`aioxmpp.dispatcher.SimpleMessageDispatcher`. """ def decorator(f): if asyncio.iscoroutinefunction(f): raise TypeError("message_handler must not be a coroutine function") aioxmpp.service.add_handler_spec( f, aioxmpp.service.HandlerSpec( (_apply_message_handler, (type_, from_)), require_deps=( SimpleMessageDispatcher, ) ) ) return f return decorator def presence_handler(type_, from_): """ Register the decorated function as presence stanza handler. :param type_: Presence type to listen for :type type_: :class:`~.PresenceType` :param from_: Sender JIDs to listen for :type from_: :class:`aioxmpp.JID` or :data:`None` :raise TypeError: if the decorated object is a coroutine function .. seealso:: :meth:`~.StanzaStream.register_presence_callback` for more details on the `type_` and `from_` arguments .. versionchanged:: 0.9 This is now based on :class:`aioxmpp.dispatcher.SimplePresenceDispatcher`. """ def decorator(f): if asyncio.iscoroutinefunction(f): raise TypeError( "presence_handler must not be a coroutine function" ) aioxmpp.service.add_handler_spec( f, aioxmpp.service.HandlerSpec( (_apply_presence_handler, (type_, from_)), require_deps=( SimplePresenceDispatcher, ) ) ) return f return decorator def is_message_handler(type_, from_, cb): """ Return true if `cb` has been decorated with :func:`message_handler` for the given `type_` and `from_`. """ try: handlers = aioxmpp.service.get_magic_attr(cb) except AttributeError: return False return aioxmpp.service.HandlerSpec( (_apply_message_handler, (type_, from_)), require_deps=( SimpleMessageDispatcher, ) ) in handlers def is_presence_handler(type_, from_, cb): """ Return true if `cb` has been decorated with :func:`presence_handler` for the given `type_` and `from_`. """ try: handlers = aioxmpp.service.get_magic_attr(cb) except AttributeError: return False return aioxmpp.service.HandlerSpec( (_apply_presence_handler, (type_, from_)), require_deps=( SimplePresenceDispatcher, ) ) in handlers aioxmpp/e2etest/000077500000000000000000000000001416014621300141045ustar00rootroot00000000000000aioxmpp/e2etest/__init__.py000066400000000000000000000356361416014621300162320ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.e2etest` --- Framework for writing integration tests for :mod:`aioxmpp` ###################################################################################### This subpackage provides utilities for writing end-to-end or intgeration tests for :mod:`aioxmpp` components. .. warning:: For now, the API of this subpackage is classified as internal. Please do not test your external components using this API, as it is experimental and subject to change. Overview ======== The basic concept is that tests are written like normal unittests. However, tests are written by inheriting classes from :class:`aioxmpp.e2etest.TestCase` instead of :mod:`unittest.TestCase`. :class:`.e2etest.TestCase` has the :attr:`~.e2etest.TestCase.provisioner` attribute which provides access to a :class:`.provision.Provisioner` instance. Provisioners are objects which provide a way to obtain a connected XMPP client. The JID to which the client is bound is unspecified; however, each client gets a unique bare JID and the clients are able to communicate with each other. In addition, provisioners provide information about the environment in which the clients act. This includes providing JIDs of entities implementing specific protocols or features. The details are explained in the documentation of the :class:`~.provision.Provisioner` base class. By default, tests which are written with :class:`.e2etest.TestCase` are skipped when using the normal test runners. This is because the provisioners need to be configured; this is handled using a custom nosetests plugin which is not loaded by default (for good reasons). To run the tests, use (instead of the normal ``nosetests3`` binary): .. code-block:: console $ python3 -m aioxmpp.e2etest The command line interface is identical to the one of ``nosetests3``, except that additional options are provided to configure the plugin. In fact, :mod:`aioxmpp.e2etest` is simply a nose test runner with an additional plugin. By default, the configuration is read from ``./.local/e2etest.ini``. For details on configuring the provisioners, see :ref:`the developer guide `. Main API ======== Decorators for test methods --------------------------- The following decorators can be used on test methods (including ``setUp`` and ``tearDown``): .. autodecorator:: require_feature .. autodecorator:: require_identity .. autodecorator:: require_feature_subset .. autodecorator:: skip_with_quirk General decorators ------------------ .. autodecorator:: blocking() .. autodecorator:: blocking_timed() .. autodecorator:: blocking_with_timeout Class for test cases -------------------- .. autoclass:: TestCase .. currentmodule:: aioxmpp.e2etest.provision Provisioners ============ .. autoclass:: Provisioner .. autoclass:: AnonymousProvisioner() .. autoclass:: AnyProvisioner() .. autoclass:: StaticPasswordProvisioner() .. currentmodule:: aioxmpp.e2etest .. autoclass:: Quirk .. currentmodule:: aioxmpp.e2etest.provision Helper functions ---------------- .. autofunction:: discover_server_features .. autofunction:: configure_tls_config .. autofunction:: configure_quirks """ # NOQA: E501 import asyncio import configparser import functools import importlib import logging import os import unittest import pytest from ..testutils import get_timeout from .utils import blocking from .provision import Quirk # NOQA: F401 provisioner = None config = None only_e2etest = False e2etest_record = None timeout = get_timeout(1.0) def require_feature(feature_var, argname=None, *, multiple=False): """ :param feature_var: :xep:`30` feature ``var`` of the required feature :type feature_var: :class:`str` :param argname: Optional argument name to pass the :class:`FeatureInfo` to :type argname: :class:`str` or :data:`None` :param multiple: If true, all peers are returned instead of a random one. :type multiple: :class:`bool` Before running the function, it is tested that the feature specified by `feature_var` is provided in the environment of the current provisioner. If it is not, :class:`unittest.SkipTest` is raised to skip the test. If the feature is available, the :class:`FeatureInfo` instance is passed to the decorated function. If `argname` is :data:`None`, the feature info is passed as additional positional argument. otherwise, it is passed as keyword argument using the `argname`. If `multiple` is true, all peers supporting the given feature are passed in a set. Otherwise, only a random peer is returned. This decorator can be used on test methods, but not on test classes. If you want to skip all tests in a class, apply the decorator to the ``setUp`` method. """ if isinstance(feature_var, str): feature_var = [feature_var] def decorator(f): @functools.wraps(f) def wrapper(*args, **kwargs): global provisioner if multiple: arg = provisioner.get_feature_providers(feature_var) has_provider = bool(arg) else: arg = provisioner.get_feature_provider(feature_var) has_provider = arg is not None if not has_provider: raise unittest.SkipTest( "provisioner does not provide a peer with " "{!r}".format(feature_var) ) if argname is None: args = args+(arg,) else: kwargs[argname] = arg return f(*args, **kwargs) return wrapper return decorator def require_identity(category, type_, argname=None): def decorator(f): @functools.wraps(f) def wrapper(*args, **kwargs): global provisioner arg = provisioner.get_identity_provider(category, type_) has_provider = arg is not None if not has_provider: raise unittest.SkipTest( "provisioner does not provide a peer with a " "{!r} identity".format((category, type_)) ) if argname is None: args = args+(arg,) else: kwargs[argname] = arg return f(*args, **kwargs) return wrapper return decorator def require_feature_subset(feature_vars, required_subset=[]): required_subset = set(required_subset) feature_vars = set(feature_vars) | required_subset def decorator(f): @functools.wraps(f) def wrapper(*args, **kwargs): global provisioner jid, subset = provisioner.get_feature_subset_provider( feature_vars, required_subset ) if jid is None: raise unittest.SkipTest( "no peer could provide a subset of {!r} with at least " "{!r}".format( feature_vars, required_subset, ) ) return f(*(args+(jid, feature_vars)), **kwargs) return wrapper return decorator def require_pep(f): @functools.wraps(f) def wrapper(*args, **kwargs): global provisioner if not provisioner.has_pep(): raise unittest.SkipTest( "the provisioned account does not support PEP", ) return f(*args, **kwargs) return wrapper def skip_with_quirk(quirk): """ :param quirk: The quirk to skip on :type quirk: :class:`Quirks` If the provisioner indicates that the environment has the given `quirk`, the test is skipped. This decorator can be used on test methods, but not on test classes. If you want to skip all tests in a class, apply the decorator to the ``setUp`` method. """ def decorator(f): @functools.wraps(f) def wrapper(*args, **kwargs): global provisioner if provisioner.has_quirk(quirk): raise unittest.SkipTest( "provisioner has quirk {!r}".format(quirk) ) return f(*args, **kwargs) return wrapper return decorator def blocking_with_timeout(timeout): """ The decorated coroutine function is run using the :meth:`~asyncio.AbstractEventLoop.run_until_complete` method of the current (at the time of call) event loop. If the execution takes longer than `timeout` seconds, :class:`asyncio.TimeoutError` is raised. The decorated function behaves like a normal function and is not a coroutine function. This decorator must be applied to a coroutine function (or method). """ def decorator(f): @blocking @functools.wraps(f) async def wrapper(*args, **kwargs): return await asyncio.wait_for(f(*args, **kwargs), timeout) return wrapper return decorator def blocking_timed(f): """ Like :func:`blocking_with_timeout`, the decorated coroutine function is executed using :meth:`asyncio.AbstractEventLoop.run_until_complete` with a timeout, but the timeout is configured in the end-to-end test configuration (see :ref:`dg-end-to-end-tests`). This is the recommended decorator for any test function or method, to prevent the tests from hanging when anythin goes wrong. The timeout is under control of the provisioner configuration, which means that it can be adapted to different setups (for example, running against an XMPP server in the internet will be slower than if it runs on localhost). The decorated function behaves like a normal function and is not a coroutine function. This decorator must be applied to a coroutine function (or method). """ @blocking @functools.wraps(f) async def wrapper(*args, **kwargs): global timeout await asyncio.wait_for(f(*args, **kwargs), timeout) return wrapper @blocking async def setup_package(): global provisioner, config, timeout if config is None: return timeout = config.getfloat("global", "timeout", fallback=timeout) provisioner_name = config.get("global", "provisioner") module_path, class_name = provisioner_name.rsplit(".", 1) mod = importlib.import_module(module_path) cls_ = getattr(mod, class_name) section = config[provisioner_name] provisioner = cls_() provisioner.configure(section) await provisioner.initialise() def teardown_package(): global provisioner, config if config is None: return loop = asyncio.get_event_loop() loop.run_until_complete(provisioner.finalise()) loop.close() class TestCase(unittest.TestCase): """ A subclass of :class:`unittest.TestCase` for end-to-end test cases. This subclass provides a single additional attribute: .. autoattribute:: provisioner """ __unittest_skip__ = True __unittest_skip_why__ = "this is not the aioxmpp test runner" @property def provisioner(self): """ This is the configured :class:`.provision.Provisioner` instance. If no provisioner is configured (for example because the e2etest nose plugin is not loaded), this reads as :data:`None`. .. note:: Under nosetests and the vanilla unittest runner, tests inheriting from :class:`TestCase` are automatically skipped if :attr:`provisioner` is :data:`None`. """ global provisioner return provisioner def pytest_load_initial_conftests(early_config, parser, args): parser.addoption( "--e2etest-config", dest="aioxmpp_e2e_config", default=".local/e2etest.ini", metavar="FILE", help="Configuration file for end-to-end tests " "(default: .local/e2etest.ini)", ) parser.addoption( "--e2etest-record", dest="aioxmpp_e2e_record", metavar="FILE", default=None, help="A file to write a transcript to" ) parser.addoption( "--e2etest-only", dest="aioxmpp_e2e_only", action="store_true", default=False, help="If set, only E2E tests will be executed." ) def pytest_configure(config): config.addinivalue_line("markers", "aioxmpp_e2etest: end-to-end test") def pytest_cmdline_main(config): return _pytest_cmdline_main_impl(config) def _pytest_cmdline_main_impl(pytest_config): global config, only_e2etest, e2etest_record config = configparser.ConfigParser() with open(pytest_config.option.aioxmpp_e2e_config, "r") as f: config.read_file(f) e2etest_record = pytest_config.option.aioxmpp_e2e_record only_e2etest = pytest_config.option.aioxmpp_e2e_only TestCase.__unittest_skip__ = False def pytest_sessionstart(session): setup_package() def pytest_sessionfinish(session): teardown_package() @pytest.hookimpl(hookwrapper=True) def pytest_pycollect_makeitem(collector, name, obj): global config, only_e2etest outcome = yield item = outcome.get_result() if isinstance(obj, type) and issubclass(obj, TestCase): if config is None: item.add_marker(pytest.mark.skip("e2e tests not enabled")) else: item.add_marker("aioxmpp_e2etest") elif isinstance(obj, type) and issubclass(obj, unittest.TestCase): if only_e2etest: item.add_marker(pytest.mark.skip("only e2e tests enabled")) def pytest_runtest_setup(item): global provisioner, e2etest_record if item.get_closest_marker("aioxmpp_e2etest") is not None: blocking(provisioner.setup)() def pytest_runtest_call(item): if e2etest_record: handler = logging.FileHandler( e2etest_record, "w", ) handler.setLevel(logging.DEBUG) formatter = logging.Formatter( "%(name)s: %(levelname)s: %(message)s", style="%" ) handler.setFormatter(formatter) logger = logging.getLogger("aioxmpp.e2etest.provision") logger.addHandler(handler) logger.setLevel(logging.DEBUG) def pytest_runtest_teardown(item): global provisioner if item.get_closest_marker("aioxmpp_e2etest") is not None: blocking(provisioner.teardown)() aioxmpp/e2etest/__main__.py000066400000000000000000000020711416014621300161760ustar00rootroot00000000000000######################################################################## # File name: __main__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import os import pathlib import sys os.chdir(str(pathlib.Path(__file__).parent.parent.parent)) os.execv( sys.executable, [sys.executable, "-m", "pytest", "-p", "aioxmpp.e2etest"] + sys.argv[1:], ) aioxmpp/e2etest/provision.py000066400000000000000000000672051416014621300165200ustar00rootroot00000000000000######################################################################## # File name: provision.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import abc import ast import asyncio import base64 import enum import fnmatch import json import logging import random import unittest import aioxmpp import aioxmpp.disco import aioxmpp.security_layer import aioxmpp.connector _logger = logging.getLogger(__name__) _rng = random.SystemRandom() class Quirk(enum.Enum): """ Enumeration of implementation quirks. Each enumeration member represents a quirk of an implementation. A quirk is a behaviour of an implementation which does not directly violate standards, but which is unfortunate in a way that it disables some features of :mod:`aioxmpp`. One example of such a quirk is the rewriting of message stanza IDs which some MUC implementations do when reflecting the messages. This breaks the stanza tracking of :meth:`aioxmpp.muc.Room.send_tracked_message`. The following quirks are defined: .. attribute:: MUC_REWRITES_MESSAGE_ID :annotation: https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#muc-id-rewrite This quirk must be configured when the environment the provisioner provides rewrites the message IDs when they are reflected by the MUC implementation. The quirk does not need to be set if the environment does not provide a MUC implementation at all. .. attribute:: PUBSUB_GET_ITEMS_BY_ID_BROKEN :annotation: https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#broken-pubsub-get-multiple-by-id Indicates that the "Get Items by Id" operation in the PubSub service used is broken when more than one item is requested. """ # NOQA: E501 MUC_REWRITES_MESSAGE_ID = \ "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#muc-id-rewrite" NO_ADHOC_PING = \ "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#no-adhoc-ping" MUC_NO_333 = \ "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#muc-no-333" BROKEN_MUC = \ "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#broken-muc" PUBSUB_GET_MULTIPLE_ITEMS_BY_ID_BROKEN = \ "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#broken-pubsub-get-multiple-by-id" # NOQA: E501 NO_PRIVATE_XML = \ "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks#no-xep-0049" def fix_quirk_str(s): if s.startswith("#"): return "https://zombofant.net/xmlns/aioxmpp/e2etest/quirks" + s return s def configure_tls_config(section): """ Generate keyword arguments for use with :meth:`.security_layer.make` from the configuration which control the TLS behaviour of the security layer. :param section: Configuration section to work on. :return: Keyword arguments for :meth:`.security_layer.make` :rtype: :class:`dict` The generated keyword arguments are ``pin_type``, ``pin_store`` and ``no_verify``. The options in the config file have the same names and the semantics are the following: ``pin_store`` and ``pin_type`` can be used to configure certificate pinning, in case the server you want to test against does not have a certificate which passes the default OpenSSL PKIX tests. If set, ``pin_store`` must point to a JSON file, which consists of a single object mapping host names to arrays of strings containing the base64 representation of what is being pinned. This is determined by ``pin_type``, which can be ``0`` for Public Key pinning and ``1`` for Certificate pinning. There is also the ``no_verify`` option, which, if set to true, will disable certificate verification altogether. This does not much harm if you are testing against localhost anyways and saves the configuration nuisance for certificate pinning. ``no_verfiy`` takes precedence over ``pin_store`` and ``pin_type``. """ no_verify = section.getboolean( "no_verify", fallback=False ) if not no_verify and "pin_store" in section: with open(section.get("pin_store")) as f: pin_store = json.load(f) pin_type = aioxmpp.security_layer.PinType( section.getint("pin_type", fallback=0) ) else: pin_store = None pin_type = None return { "pin_store": pin_store, "pin_type": pin_type, "no_verify": no_verify, } def configure_quirks(section): """ Generate a set of :class:`.Quirk` enum members from the given configuration section. :param section: Configuration section to work on. :return: Set of :class:`.Quirk` members This parses the configuration key ``quirks`` as a python literal (see :func:`ast.literal_eval`). It expects a list of strings as a result. The strings are interpreted as :class:`.Quirk` enum values. If a string starts with ``#``, it is prefixed with ``https://zombofant.net/xmlns/aioxmpp/e2etest/quirks`` for easier manual writing of the configuration. See :class:`.Quirk` for the currently defined quirks. """ quirks = ast.literal_eval(section.get("quirks", fallback="[]")) if isinstance(quirks, (str, dict)): raise ValueError("incorrect type for quirks setting") return set(map(Quirk, map(fix_quirk_str, quirks))) def configure_blockmap(section): blockmap_raw = ast.literal_eval(section.get("block_features", fallback="{}")) return { aioxmpp.JID.fromstr(entity): features for entity, features in blockmap_raw.items() } def _is_feature_blocked(peer, feature, blockmap): return any( fnmatch.fnmatch(feature, item) for item in blockmap.get(peer, []) ) async def discover_server_features(disco, peer, recurse_into_items=True, blockmap={}): """ Use :xep:`30` service discovery to discover features supported by the server. :param disco: Service discovery client which can query the `peer` server. :type disco: :class:`aioxmpp.DiscoClient` :param peer: The JID of the server to query :type peer: :class:`~aioxmpp.JID` :param recurse_into_items: If set to true, the :xep:`30` items exposed by the server will also be queried for their features. Only one level of recursion is performed. :return: A mapping which maps :xep:`30` feature vars to the JIDs at which the service is provided. This uses :xep:`30` service discovery to obtain a set of features supported at `peer`. The set of features is returned as a mapping which maps the ``var`` values of the features to the JID at which they were discovered. If `recurse_into_items` is true, a :xep:`30` items query is run against `peer`. For each JID discovered that way, :func:`discover_server_features` is re-invoked (with `recurse_into_items` set to false). The resulting mappings are merged with the mapping obtained from querying the features of `peer` (existing entries are *not* overridden -- so `peer` takes precedence). """ server_info = await disco.query_info(peer) all_features = { feature: [peer] for feature in server_info.features if not _is_feature_blocked(peer, feature, blockmap) } if recurse_into_items: server_items = await disco.query_items(peer) features_list = await asyncio.gather( *( discover_server_features( disco, item.jid, recurse_into_items=False, ) for item in server_items.items if item.jid is not None and item.node is None ) ) for features in features_list: for feature, providers in features.items(): all_features.setdefault(feature, []).extend(providers) return all_features async def discover_server_identities(disco, peer, recurse_into_items=True): """ Use :xep:`30` service discovery to discover identities provided by the server. :param disco: Service discovery client which can query the `peer` server. :type disco: :class:`aioxmpp.DiscoClient` :param peer: The JID of the server to query :type peer: :class:`~aioxmpp.JID` :param recurse_into_items: If set to true, the :xep:`30` items exposed by the server will also be queried for their identities. Only one level of recursion is performed. :return: A mapping which maps :xep:`30` (category, type) tuples to the JIDs at which the identity is provided. This uses :xep:`30` service discovery to obtain a set of identities offered at `peer`. The set of identities is returned as a mapping which maps the ``(category, type)`` tuples of the identities to the JID at which they were discovered. If `recurse_into_items` is true, a :xep:`30` items query is run against `peer`. For each JID discovered that way, :func:`discover_server_identities` is re-invoked (with `recurse_into_items` set to false). The resulting mappings are merged with the mapping obtained from querying the identities of `peer` (existing entries are *not* overridden -- so `peer` takes precedence). """ server_info = await disco.query_info(peer) all_identities = { (identity.category, identity.type_): [peer] for identity in server_info.identities } if recurse_into_items: server_items = await disco.query_items(peer) identities_list = await asyncio.gather( *( discover_server_identities( disco, item.jid, recurse_into_items=False, ) for item in server_items.items if item.jid is not None and item.node is None ) ) for identities in identities_list: for identity, providers in identities.items(): all_identities.setdefault(identity, []).extend(providers) return all_identities class Provisioner(metaclass=abc.ABCMeta): """ Base class for provisioners. Provisioners are responsible for providing test cases with XMPP accounts and client objects connected to these accounts, as well as information about the environment the accounts live in. A provisioner must implement the following methods: .. automethod:: _make_client .. automethod:: configure The following methods are the API used by test cases: .. automethod:: get_connected_client .. automethod:: get_feature_provider .. automethod:: get_identity_provider .. automethod:: has_quirk These methods can be used by provisioners to perform plumbing tasks, such as shutting down clients or deleting accounts: .. automethod:: initialise .. automethod:: finalise .. automethod:: setup .. automethod:: teardown """ def __init__(self, logger=_logger): super().__init__() self._accounts_to_dispose = [] self._featuremap = {} self._identitymap = {} self._account_info = None self._logger = logger self.__counter = 0 @abc.abstractmethod async def _make_client(self, logger): """ :param logger: The logger to pass to the client. :return: Client with a fresh account. Construct a new :class:`aioxmpp.PresenceManagedClient` connected to a new account. This method must be re-implemented by subclasses. """ async def get_connected_client(self, presence=aioxmpp.PresenceState(True), *, services=[], prepare=None): """ Return a connected client to a unique XMPP account. :param presence: initial presence to emit :type presence: :class:`aioxmpp.PresenceState` :param prepare: a coroutine run after the services are summoned but before the client connects. :type prepare: coroutine receiving the client as argument :raise OSError: if the connection failed :raise RuntimeError: if a client could not be provisioned due to resource constraints :return: Connected presence managed client :rtype: :class:`aioxmpp.PresenceManagedClient` Each account used by the clients returned from this method is unique; all clients are guaranteed to have different bare JIDs. The clients and accounts are cleaned up after the tear down of the test runs. Some provisioners may have a limit on the number of accounts which can be used in the same test. Clients obtained from this function are cleaned up automatically on tear down of the test. The clients are stopped and the accounts deleted or cleared, so that each test starts with a fully fresh state. A coroutine may be passed as `prepare` argument. It is called with the client as the single argument after all services in `services` have been summoned but before the client connects, this is for example useful to connect signals that fire early in the connection process. """ id_ = self.__counter self.__counter += 1 self._logger.debug("obtaining client%d from %r", id_, self) logger = self._logger.getChild("client{}".format(id_)) client = await self._make_client(logger) for service in services: client.summon(service) if prepare is not None: await prepare(client) cm = client.connected(presence=presence) await cm.__aenter__() self._accounts_to_dispose.append(cm) return client def get_feature_providers(self, feature_nses): """ :param feature_ns: Namespace URIs to find a provider for :type feature_ns: iterable of :class:`str` :return: JIDs of the entities providing all features :rtype: :class:`set` of :class:`aioxmpp.JID` If there is no entity supporting all requested features, the empty set is returned. """ providers = set() iterator = iter(feature_nses) try: first_ns = next(iterator) except StopIteration: return None providers = set(self._featuremap.get(first_ns, [])) for feature_ns in iterator: providers &= set(self._featuremap.get(feature_ns, [])) return providers def get_feature_provider(self, feature_nses): """ :param feature_ns: Namespace URIs to find a provider for :type feature_ns: iterable of :class:`str` :return: JID of the entity providing all features :rtype: :class:`aioxmpp.JID` If there is no entity supporting all requested features, :data:`None` is returned. """ providers = self.get_feature_providers(feature_nses) if not providers: return None return next(iter(providers)) def get_identity_provider(self, category, type_): return next(iter(self._identitymap.get((category, type_), []))) def get_feature_subset_provider(self, feature_nses, required_subset): required_subset = set(required_subset) candidates = {} for feature_ns in feature_nses: providers = self._featuremap.get(feature_ns, []) for provider in providers: candidates.setdefault(provider, set()).add(feature_ns) candidates = sorted( ( (provider, features) for provider, features in candidates.items() if features & required_subset == required_subset ), key=lambda x: (len(x[1])) ) try: return candidates.pop() except IndexError: return None, None def has_quirk(self, quirk): """ :param quirk: Quirk to check for :type quirk: :class:`Quirk` :return: true if the environment has the given quirk """ return quirk in self._quirks def has_pep(self): """ :return: true if the account has PEP support, false otherwise. """ if not self._account_info: return False return any(ident.category == "pubsub" and ident.type_ == "pep" for ident in self._account_info.identities) @abc.abstractmethod def configure(self, section): """ Read the configuration and set up the provisioner. :param section: mapping of config keys to values Subclasses will implement this to configure their account setup and servers to use. .. seealso:: :func:`configure_tls_config` for a function which extracts TLS-related arguments for :func:`aioxmpp.security_layer.make` :func:`configure_quirks` for a function which extracts a set of :class:`.Quirk` enumeration members from the configuration :func:`configure_blockmap` for a function which extracts a mapping which allows to block features from specific hosts """ async def initialise(self): """ Called once on test framework startup. Subclasses may run service discovery code here to detect features of the environment they are connected to. .. seealso:: :func:`discover_server_features` for a function which uses :xep:`30` service discovery to find features. """ async def finalise(self): """ Called once on test framework shutdown (timeout of 10 seconds applies). """ async def setup(self): """ Called before each test run. """ async def teardown(self): """ Called after each test run. The default implementation cleans up the clients obtained from :meth:`get_connected_client`. """ futures = [] for cm in self._accounts_to_dispose: futures.append(asyncio.ensure_future( cm.__aexit__(None, None, None) )) self._accounts_to_dispose.clear() self._logger.debug("waiting for %d accounts to shut down", len(futures)) await asyncio.gather( *futures, return_exceptions=True ) class _AutoConfiguredProvisioner(Provisioner): def configure(self, section): super().configure(section) self._blockmap = configure_blockmap(section) async def initialise(self): self._logger.debug("auto-configuring provisioner %s", self) client = await self.get_connected_client() disco = client.summon(aioxmpp.DiscoClient) self._featuremap.update(await discover_server_features( disco, self._domain, blockmap=self._blockmap, )) self._identitymap.update(await discover_server_identities( disco, self._domain, )) self._logger.debug("found %d features", len(self._featuremap)) if self._logger.isEnabledFor(logging.DEBUG): for feature, providers in self._featuremap.items(): self._logger.debug( "%s provided by %s", feature, ", ".join(sorted(map(str, providers))) ) self._account_info = await disco.query_info(None) # clean up state del client await self.teardown() class AnonymousProvisioner(_AutoConfiguredProvisioner): """ This provisioner uses SASL ANONYMOUS to obtain accounts. It is dead-simple to configure: it needs a host to connect to, and optionally some TLS and quirks configuration. The host is specified as configuration key ``host``, TLS can be configured as documented in :func:`configure_tls_config` and quirks are set as described in :func:`configure_quirks`. A configuration for a locally running Prosody instance might look like this: .. code-block:: ini [aioxmpp.e2etest.provision.AnonymousProvisioner] host=localhost no_verify=true quirks=[] The server configured in ``host`` must support SASL ANONYMOUS and must allow communication between the clients connected that way. It may provide PubSub and/or MUC services, which will be auto-discovered if they are provided in the :xep:`30` items of the server. """ def configure(self, section): super().configure(section) self.__host = section.get("host") self._domain = aioxmpp.JID.fromstr(section.get( "domain", self.__host )) self.__port = section.getint("port") self.__security_layer = aioxmpp.make_security_layer( None, anonymous="", **configure_tls_config( section ) ) self._quirks = configure_quirks(section) async def _make_client(self, logger): override_peer = [] if self.__port is not None: override_peer.append( (self.__host, self.__port, aioxmpp.connector.STARTTLSConnector()) ) return aioxmpp.PresenceManagedClient( self._domain, self.__security_layer, override_peer=override_peer, logger=logger, ) class AnyProvisioner(_AutoConfiguredProvisioner): """ This provisioner randomly generates usernames and uses a hardcoded password to authenticate with the XMPP server. This is for use with ``mod_auth_any`` of prosody. It is dead-simple to configure: it needs a host to connect to, and optionally some TLS and quirks configuration. The host is specified as configuration key ``host``, TLS can be configured as documented in :func:`configure_tls_config` and quirks are set as described in :func:`configure_quirks`. A configuration for a locally running Prosody instance might look like this: .. code-block:: ini [aioxmpp.e2etest.provision.AnyProvisioner] host=localhost no_verify=true quirks=[] The server configured in ``host`` must allow authentication with any username/password pair and allow communication between the clients connected that way. It may provide PubSub and/or MUC services, which will be auto-discovered if they are provided in the :xep:`30` items of the server. """ def configure(self, section): super().configure(section) self.__host = section.get("host") self._domain = aioxmpp.JID.fromstr(section.get( "domain", self.__host )) self.__port = section.getint("port") self.__security_layer = aioxmpp.make_security_layer( "foobar2342", # password is irrelevant, but must be given. **configure_tls_config( section ) ) self._quirks = configure_quirks(section) self.__username_rng = random.Random() self.__username_rng.seed(_rng.getrandbits(256)) async def _make_client(self, logger): override_peer = [] if self.__port is not None: override_peer.append( (self.__host, self.__port, aioxmpp.connector.STARTTLSConnector()) ) user = base64.b32encode( self.__username_rng.getrandbits(128).to_bytes(128//8, 'little') ).decode("ascii").rstrip("=") user_jid = self._domain.replace(localpart=user) return aioxmpp.PresenceManagedClient( user_jid, self.__security_layer, override_peer=override_peer, logger=logger, ) class StaticPasswordProvisioner(_AutoConfiguredProvisioner): """ This provisioner expects a list of username/password pairs to authenticate against the tested server. This is for use with servers which support neither SASL ANONYMOUS nor a ``mod_auth_any`` equivalent. The configuration of this provisioner is slightly unwieldy since we do not want to add a dependency to a more sane configuration file format. Here is an example on how to configure a provisioner with two accounts: .. code-block:: ini [aioxmpp.e2etest.provision.StaticPasswordProvisioner] host=localhost accounts=[("user1", "password1"), ("user2", "password2")] skip_on_too_few_accounts=false All accounts need to have exactly the same privileges on the server. The first account will be used to auto-discover any features offered by the test environment. If `skip_on_too_few_accounts` is set to true (the default is false), tests will be skipped if the provisioner runs out of accounts instead of failing. """ def _load_accounts(self, cfg): result = [] for username, password in ast.literal_eval(cfg): result.append(( aioxmpp.JID(localpart=username, domain=self._domain.domain, resource=None), aioxmpp.make_security_layer(password, **self.__tls_config) )) return result def configure(self, section): super().configure(section) self.__host = section.get("host") self._domain = aioxmpp.JID.fromstr(section.get( "domain", self.__host )) self.__port = section.getint("port") self.__tls_config = configure_tls_config(section) self.__accounts = self._load_accounts(section.get("accounts")) if len(self.__accounts) == 0: raise RuntimeError( "at least one account needs to be configured in the " "StaticPasswordProvisioner section" ) self.__nused_accounts = 0 self._quirks = configure_quirks(section) self.__username_rng = random.Random() self.__skip_on_too_few_accounts = section.getboolean( "skip_on_too_few_accounts", fallback=False, ) async def _make_client(self, logger): override_peer = [] if self.__port is not None: override_peer.append( (self.__host, self.__port, aioxmpp.connector.STARTTLSConnector()) ) next_account = self.__nused_accounts try: address, security_layer = self.__accounts[next_account] except IndexError: err = ( "not enough accounts; needed at least one more account " "after already using {} accounts".format(next_account) ) if self.__skip_on_too_few_accounts: raise unittest.SkipTest(err) raise RuntimeError(err) self.__nused_accounts += 1 return aioxmpp.PresenceManagedClient( address, security_layer, override_peer=override_peer, logger=logger, ) async def teardown(self): await super().teardown() self.__nused_accounts = 0 aioxmpp/e2etest/utils.py000066400000000000000000000026621416014621300156240ustar00rootroot00000000000000######################################################################## # 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/000077500000000000000000000000001416014621300147145ustar00rootroot00000000000000aioxmpp/entitycaps/__init__.py000066400000000000000000000035161416014621300170320ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.entitycaps` --- Entity Capabilities support (:xep:`390`, :xep:`0115`) #################################################################################### This module provides support for :xep:`XEP-0115 (Entity Capabilities) <0115>` and :xep:`XEP-0390 (Entity Capabilities 2.0) <0390>`. To use it, :meth:`.Client.summon` the :class:`aioxmpp.EntityCapsService` on a :class:`~.Client`. See the service documentation for more information. .. versionadded:: 0.5 .. versionchanged:: 0.9 Support for :xep:`390` was added. Service ======= .. currentmodule:: aioxmpp .. autoclass:: EntityCapsService .. currentmodule:: aioxmpp.entitycaps .. class:: Service Alias of :class:`.EntityCapsService`. .. deprecated:: 0.8 The alias will be removed in 1.0. .. autoclass:: Cache .. currentmodule:: aioxmpp.entitycaps.xso """ # NOQA: E501 from .service import EntityCapsService, Cache # NOQA: F401 from . import xso # NOQA: F401 Service = EntityCapsService aioxmpp/entitycaps/caps115.py000066400000000000000000000113101416014621300164370ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000124711416014621300164550ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000075001416014621300165600ustar00rootroot00000000000000######################################################################## # File name: common.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import abc class AbstractKey(metaclass=abc.ABCMeta): @abc.abstractproperty def path(self): """ Return the file system path relative to the root of a file-system based caps database for this key. The path includes all information of the key. Components of the path do not exceed 255 codepoints and use only ASCII codepoints. If it is not possible to create such a path, :class:`ValueError` is raised. """ @abc.abstractmethod def verify(self, query_response): """ Verify whether the cache key matches a piece of service discovery information. :param query_response: The full :xep:`30` disco#info query response. :type query_response: :class:`~.disco.xso.InfoQuery` :rtype: :class:`bool` :return: true if the key matches and false otherwise. """ class AbstractImplementation(metaclass=abc.ABCMeta): @abc.abstractmethod def extract_keys(self, presence): """ Extract cache keys from a presence stanza. :param presence: Presence stanza to extract cache keys from. :type presence: :class:`aioxmpp.Presence` :rtype: :class:`~collections.abc.Iterable` of :class:`AbstractKey` :return: The cache keys from the presence stanza. The resulting iterable may be empty if the presence stanza does not carry any capabilities information with it. The resulting iterable cannot be iterated over multiple times. """ @abc.abstractmethod def put_keys(self, keys, presence): """ Insert cache keys into a presence stanza. :param keys: An iterable of cache keys to insert. :type keys: :class:`~collections.abc.Iterable` of :class:`AbstractKey` objects :param presence: The presence stanza into which the cache keys shall be injected. :type presence: :class:`aioxmpp.Presence` The presence stanza is modified in-place. """ @abc.abstractmethod def calculate_keys(self, query_response): """ Calculate the cache keys for a disco#info response. :param query_response: The full :xep:`30` disco#info query response. :type query_response: :class:`~.disco.xso.InfoQuery` :rtype: :class:`~collections.abc.Iterable` of :class:`AbstractKey` :return: An iterable of the cache keys for the disco#info response. .. :param identities: The identities of the disco#info response. :type identities: :class:`~collections.abc.Iterable` of :class:`~.disco.xso.Identity` :param features: The features of the disco#info response. :type features: :class:`~collections.abc.Iterable` of :class:`str` :param features: The extensions of the disco#info response. :type features: :class:`~collections.abc.Iterable` of :class:`~.forms.xso.Data` """ aioxmpp/entitycaps/service.py000066400000000000000000000377721416014621300167460ustar00rootroot00000000000000######################################################################## # File name: service.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import asyncio import collections import copy import functools import logging import os import tempfile import aioxmpp.callbacks import aioxmpp.disco as disco import aioxmpp.service import aioxmpp.utils import aioxmpp.xml import aioxmpp.xso from aioxmpp.utils import namespaces from . import caps115, caps390 logger = logging.getLogger("aioxmpp.entitycaps") class Cache: """ This provides a two-level cache for entity capabilities information. The idea is to have a trusted database, e.g. installed system-wide or shipped with :mod:`aioxmpp` and in addition a user-level database which is automatically filled with hashes which have been found by the :class:`Service`. The trusted database is taken as read-only and overrides the user-collected database. When a hash is in both databases, it is removed from the user-collected database (to save space). In addition to serving the databases, it provides deduplication for queries by holding a cache of futures looking up the same hash. Database management (user API): .. automethod:: set_system_db_path .. automethod:: set_user_db_path Queries (API intended for :class:`Service`): .. automethod:: create_query_future .. automethod:: lookup_in_database .. automethod:: lookup """ def __init__(self): self._lookup_cache = {} self._memory_overlay = {} self._system_db_path = None self._user_db_path = None def _erase_future(self, key, fut): try: existing = self._lookup_cache[key] except KeyError: pass else: if existing is fut: del self._lookup_cache[key] def set_system_db_path(self, path): self._system_db_path = path def set_user_db_path(self, path): self._user_db_path = path def lookup_in_database(self, key): try: result = self._memory_overlay[key] except KeyError: pass else: logger.debug("memory cache hit: %s", key) return result key_path = key.path if self._system_db_path is not None: try: f = ( self._system_db_path / key_path ).open("rb") except OSError: pass else: logger.debug("system db hit: %s", key) with f: return aioxmpp.xml.read_single_xso(f, disco.xso.InfoQuery) if self._user_db_path is not None: try: f = ( self._user_db_path / key_path ).open("rb") except OSError: pass else: logger.debug("user db hit: %s", key) with f: return aioxmpp.xml.read_single_xso(f, disco.xso.InfoQuery) raise KeyError(key) async def lookup(self, key): """ Look up the given `node` URL using the given `hash_` first in the database and then by waiting on the futures created with :meth:`create_query_future` for that node URL and hash. If the hash is not in the database, :meth:`lookup` iterates as long as there are pending futures for the given `hash_` and `node`. If there are no pending futures, :class:`KeyError` is raised. If a future raises a :class:`ValueError`, it is ignored. If the future returns a value, it is used as the result. """ try: result = self.lookup_in_database(key) except KeyError: pass else: return result while True: fut = self._lookup_cache[key] try: result = await fut except ValueError: continue else: return result def create_query_future(self, key): """ Create and return a :class:`asyncio.Future` for the given `hash_` function and `node` URL. The future is referenced internally and used by any calls to :meth:`lookup` which are made while the future is pending. The future is removed from the internal storage automatically when a result or exception is set for it. This allows for deduplication of queries for the same hash. """ fut = asyncio.Future() fut.add_done_callback( functools.partial(self._erase_future, key) ) self._lookup_cache[key] = fut return fut def add_cache_entry(self, key, entry): """ Add the given `entry` (which must be a :class:`~.disco.xso.InfoQuery` instance) to the user-level database keyed with the hash function type `hash_` and the `node` URL. The `entry` is **not** validated to actually map to `node` with the given `hash_` function, it is expected that the caller performs the validation. """ copied_entry = copy.copy(entry) self._memory_overlay[key] = copied_entry if self._user_db_path is not None: asyncio.ensure_future(asyncio.get_event_loop().run_in_executor( None, writeback, self._user_db_path / key.path, entry.captured_events)) class EntityCapsService(aioxmpp.service.Service): """ Make use and provide service discovery information in presence broadcasts. This service implements :xep:`0115` and :xep:`0390`, transparently. Besides loading the service, no interaction is required to get some of the benefits of :xep:`0115` and :xep:`0390`. Two additional things need to be done by users to get full support and performance: 1. To make sure that peers are always up-to-date with the current capabilities, it is required that users listen on the :meth:`on_ver_changed` signal and re-emit their current presence when it fires. .. note:: Keeping peers up-to-date is a MUST in :xep:`390`. The service takes care of attaching capabilities information on the outgoing stanza, using a stanza filter. .. warning:: :meth:`on_ver_changed` may be emitted at a considerable rate when services are loaded or certain features (such as PEP-based services) are configured. It is up to the application to limit the rate at which presences are sent for the sole purpose of updating peers with new capability information. 2. Users should use a process-wide :class:`Cache` instance and assign it to the :attr:`cache` of each :class:`.entitycaps.Service` they use. This improves performance by sharing (verified) hashes among :class:`Service` instances. In addition, the hashes should be saved and restored on shutdown/start of the process. See the :class:`Cache` for details. .. signal:: on_ver_changed The signal emits whenever the Capability Hashset of the local client changes. This happens when the set of features or identities announced in the :class:`.DiscoServer` changes. .. autoattribute:: cache .. autoattribute:: xep115_support .. autoattribute:: xep390_support .. versionchanged:: 0.8 This class was formerly known as :class:`aioxmpp.entitycaps.Service`. It is still available under that name, but the alias will be removed in 1.0. .. versionchanged:: 0.9 Support for :xep:`390` was added. """ ORDER_AFTER = { disco.DiscoClient, disco.DiscoServer, } NODE = "http://aioxmpp.zombofant.net/" on_ver_changed = aioxmpp.callbacks.Signal() def __init__(self, node, **kwargs): super().__init__(node, **kwargs) self.__current_keys = {} self._cache = Cache() self.disco_server = self.dependencies[disco.DiscoServer] self.disco_client = self.dependencies[disco.DiscoClient] self.__115 = caps115.Implementation(self.NODE) self.__390 = caps390.Implementation( aioxmpp.hashes.default_hash_algorithms ) self.__active_hashsets = [] self.__key_users = collections.Counter() @property def xep115_support(self): """ Boolean to control whether :xep:`115` support is enabled or not. Defaults to :data:`True`. If set to false, inbound :xep:`115` capabilities will not be processed and no :xep:`115` capabilities will be emitted. .. note:: At some point, this will default to :data:`False` to save bandwidth. The exact release depends on the adoption of :xep:`390` and will be announced in time. If you depend on :xep:`115` support, set this boolean to :data:`True`. The attribute itself will not be removed until :xep:`115` support is removed from :mod:`aioxmpp` entirely, which is unlikely to happen any time soon. .. versionadded:: 0.9 """ return self._xep115_feature.enabled @xep115_support.setter def xep115_support(self, value): self._xep115_feature.enabled = value @property def xep390_support(self): """ Boolean to control whether :xep:`390` support is enabled or not. Defaults to :data:`True`. If set to false, inbound :xep:`390` Capability Hash Sets will not be processed and no Capability Hash Sets or Capability Nodes will be generated. The hash algorithms used for generating Capability Hash Sets are those from :data:`aioxmpp.hashes.default_hash_algorithms`. """ return self._xep390_feature.enabled @xep390_support.setter def xep390_support(self, value): self._xep390_feature.enabled = value @property def cache(self): """ The :class:`Cache` instance used for this :class:`Service`. Deleting this attribute will automatically create a new :class:`Cache` instance. The attribute can be used to share a single :class:`Cache` among multiple :class:`Service` instances. """ return self._cache @cache.setter def cache(self, v): self._cache = v @cache.deleter def cache(self): self._cache = Cache() @aioxmpp.service.depsignal( disco.DiscoServer, "on_info_changed") def _info_changed(self): self.logger.debug("info changed, scheduling re-calculation of version") asyncio.get_event_loop().call_soon( self.update_hash ) async def _shutdown(self): for group in self.__current_keys.values(): for key in group: self.disco_server.unmount_node(key.node) async def query_and_cache(self, jid, key, fut): data = await self.disco_client.query_info( jid, node=key.node, require_fresh=True, no_cache=True, # the caps node is never queried by apps ) try: if key.verify(data): self.cache.add_cache_entry(key, data) fut.set_result(data) else: raise ValueError("hash mismatch") except ValueError as exc: fut.set_exception(exc) return data async def lookup_info(self, jid, keys): for key in keys: try: info = await self.cache.lookup(key) except KeyError: continue self.logger.debug("found %s in cache", key) return info first_key = keys[0] self.logger.debug("using key %s to query peer", first_key) fut = self.cache.create_query_future(first_key) info = await self.query_and_cache( jid, first_key, fut ) self.logger.debug("%s maps to %r", key, info) return info @aioxmpp.service.outbound_presence_filter def handle_outbound_presence(self, presence): if (presence.type_ == aioxmpp.structs.PresenceType.AVAILABLE and self.__active_hashsets): current_hashset = self.__active_hashsets[-1] try: keys = current_hashset[self.__115] except KeyError: pass else: self.__115.put_keys(keys, presence) try: keys = current_hashset[self.__390] except KeyError: pass else: self.__390.put_keys(keys, presence) return presence @aioxmpp.service.inbound_presence_filter def handle_inbound_presence(self, presence): keys = [] if self.xep390_support: keys.extend(self.__390.extract_keys(presence)) if self.xep115_support: keys.extend(self.__115.extract_keys(presence)) if keys: lookup_task = aioxmpp.utils.LazyTask( self.lookup_info, presence.from_, keys, ) self.disco_client.set_info_future( presence.from_, None, lookup_task ) return presence def _push_hashset(self, node, hashset): if self.__active_hashsets and hashset == self.__active_hashsets[-1]: return False for group in hashset.values(): for key in group: if not self.__key_users[key.node]: self.disco_server.mount_node(key.node, node) self.__key_users[key.node] += 1 self.__active_hashsets.append(hashset) for expired in self.__active_hashsets[:-3]: for group in expired.values(): for key in group: self.__key_users[key.node] -= 1 if not self.__key_users[key.node]: self.disco_server.unmount_node(key.node) del self.__key_users[key.node] del self.__active_hashsets[:-3] return True def update_hash(self): node = disco.StaticNode.clone(self.disco_server) info = node.as_info_xso() new_hashset = {} if self.xep115_support: new_hashset[self.__115] = set(self.__115.calculate_keys(info)) if self.xep390_support: new_hashset[self.__390] = set(self.__390.calculate_keys(info)) self.logger.debug("new hashset=%r", new_hashset) if self._push_hashset(node, new_hashset): self.on_ver_changed() # declare those at the bottom so that on_ver_changed gets emitted when the # service is instantiated _xep115_feature = disco.register_feature(namespaces.xep0115_caps) _xep390_feature = disco.register_feature(namespaces.xep0390_caps) def writeback(path, captured_events): aioxmpp.utils.mkdir_exist_ok(path.parent) with tempfile.NamedTemporaryFile(dir=str(path.parent), delete=False) as tmpf: try: generator = aioxmpp.xml.XMPPXMLGenerator( tmpf, short_empty_elements=True) generator.startDocument() aioxmpp.xso.events_to_sax(captured_events, generator) generator.endDocument() except: # NOQA os.unlink(tmpf.name) raise os.replace(tmpf.name, str(path)) aioxmpp/entitycaps/xso.py000066400000000000000000000043021416014621300160760ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000501141416014621300144200ustar00rootroot00000000000000######################################################################## # File name: errors.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.errors` --- Exception classes ############################################ Exception classes mapping to XMPP stream errors =============================================== .. autoclass:: StreamError .. autoclass:: StreamErrorCondition Exception classes mapping to XMPP stanza errors =============================================== .. autoclass:: StanzaError .. autoclass:: XMPPError .. currentmodule:: aioxmpp .. autoclass:: ErrorCondition .. autoclass:: XMPPAuthError .. autoclass:: XMPPModifyError .. autoclass:: XMPPCancelError .. autoclass:: XMPPWaitError .. autoclass:: XMPPContinueError .. currentmodule:: aioxmpp.errors .. autoclass:: ErroneousStanza Stream negotiation exceptions ============================= .. autoclass:: StreamNegotiationFailure .. autoclass:: SecurityNegotiationFailure .. autoclass:: SASLUnavailable .. autoclass:: TLSFailure .. autoclass:: TLSUnavailable I18N exceptions =============== .. autoclass:: UserError .. autoclass:: UserValueError Other exceptions ================ .. autoclass:: MultiOSError .. autoclass:: GatherError """ import enum import gettext import warnings from . import xso, i18n, structs from .utils import namespaces def format_error_text( condition, text=None, application_defined_condition=None): error_tag = xso.tag_to_str(condition.value) if application_defined_condition is not None: error_tag += "/{}".format( xso.tag_to_str(application_defined_condition.TAG) ) if text: error_tag += " ({!r})".format(text) return error_tag class ErrorCondition(structs.CompatibilityMixin, xso.XSOEnumMixin, enum.Enum): """ Enumeration to represent a :rfc:`6120` stanza error condition. Please see :rfc:`6120`, section 8.3.3, for the semantics of the individual conditions. .. versionadded:: 0.10 .. attribute:: BAD_REQUEST :annotation: = namespaces.stanzas, "bad-request" .. attribute:: CONFLICT :annotation: = namespaces.stanzas, "conflict" .. attribute:: FEATURE_NOT_IMPLEMENTED :annotation: = namespaces.stanzas, "feature-not-implemented" .. attribute:: FORBIDDEN :annotation: = namespaces.stanzas, "forbidden" .. attribute:: GONE :annotation: = namespaces.stanzas, "gone" .. attribute:: xso_class .. attribute:: new_address The text content of the ```` element represtenting the URI at which the entity can now be found. May be :data:`None` if there is no such URI. .. attribute:: INTERNAL_SERVER_ERROR :annotation: = namespaces.stanzas, "internal-server-error" .. attribute:: ITEM_NOT_FOUND :annotation: = namespaces.stanzas, "item-not-found" .. attribute:: JID_MALFORMED :annotation: = namespaces.stanzas, "jid-malformed" .. attribute:: NOT_ACCEPTABLE :annotation: = namespaces.stanzas, "not-acceptable" .. attribute:: NOT_ALLOWED :annotation: = namespaces.stanzas, "not-allowed" .. attribute:: NOT_AUTHORIZED :annotation: = namespaces.stanzas, "not-authorized" .. attribute:: POLICY_VIOLATION :annotation: = namespaces.stanzas, "policy-violation" .. attribute:: RECIPIENT_UNAVAILABLE :annotation: = namespaces.stanzas, "recipient-unavailable" .. attribute:: REDIRECT :annotation: = namespaces.stanzas, "redirect" .. attribute:: xso_class .. attribute:: new_address The text content of the ```` element represtenting the URI at which the entity can currently be found. May be :data:`None` if there is no such URI. .. attribute:: REGISTRATION_REQUIRED :annotation: = namespaces.stanzas, "registration-required" .. attribute:: REMOTE_SERVER_NOT_FOUND :annotation: = namespaces.stanzas, "remote-server-not-found" .. attribute:: REMOTE_SERVER_TIMEOUT :annotation: = namespaces.stanzas, "remote-server-timeout" .. attribute:: RESOURCE_CONSTRAINT :annotation: = namespaces.stanzas, "resource-constraint" .. attribute:: SERVICE_UNAVAILABLE :annotation: = namespaces.stanzas, "service-unavailable" .. attribute:: SUBSCRIPTION_REQUIRED :annotation: = namespaces.stanzas, "subscription-required" .. attribute:: UNDEFINED_CONDITION :annotation: = namespaces.stanzas, "undefined-condition" .. attribute:: UNEXPECTED_REQUEST :annotation: = namespaces.stanzas, "unexpected-request" """ BAD_REQUEST = (namespaces.stanzas, "bad-request") CONFLICT = (namespaces.stanzas, "conflict") FEATURE_NOT_IMPLEMENTED = (namespaces.stanzas, "feature-not-implemented") FORBIDDEN = (namespaces.stanzas, "forbidden") GONE = (namespaces.stanzas, "gone") INTERNAL_SERVER_ERROR = (namespaces.stanzas, "internal-server-error") ITEM_NOT_FOUND = (namespaces.stanzas, "item-not-found") JID_MALFORMED = (namespaces.stanzas, "jid-malformed") NOT_ACCEPTABLE = (namespaces.stanzas, "not-acceptable") NOT_ALLOWED = (namespaces.stanzas, "not-allowed") NOT_AUTHORIZED = (namespaces.stanzas, "not-authorized") POLICY_VIOLATION = (namespaces.stanzas, "policy-violation") RECIPIENT_UNAVAILABLE = (namespaces.stanzas, "recipient-unavailable") REDIRECT = (namespaces.stanzas, "redirect") REGISTRATION_REQUIRED = (namespaces.stanzas, "registration-required") REMOTE_SERVER_NOT_FOUND = (namespaces.stanzas, "remote-server-not-found") REMOTE_SERVER_TIMEOUT = (namespaces.stanzas, "remote-server-timeout") RESOURCE_CONSTRAINT = (namespaces.stanzas, "resource-constraint") SERVICE_UNAVAILABLE = (namespaces.stanzas, "service-unavailable") SUBSCRIPTION_REQUIRED = (namespaces.stanzas, "subscription-required") UNDEFINED_CONDITION = (namespaces.stanzas, "undefined-condition") UNEXPECTED_REQUEST = (namespaces.stanzas, "unexpected-request") ErrorCondition.GONE.xso_class.new_address = xso.Text() ErrorCondition.REDIRECT.xso_class.new_address = xso.Text() class StreamErrorCondition(structs.CompatibilityMixin, xso.XSOEnumMixin, enum.Enum): """ Enumeration to represent a :rfc:`6120` stream error condition. Please see :rfc:`6120`, section 4.9.3, for the semantics of the individual conditions. .. versionadded:: 0.10 .. attribute:: BAD_FORMAT :annotation: = (namespaces.streams, "bad-format") .. attribute:: BAD_NAMESPACE_PREFIX :annotation: = (namespaces.streams, "bad-namespace-prefix") .. attribute:: CONFLICT :annotation: = (namespaces.streams, "conflict") .. attribute:: CONNECTION_TIMEOUT :annotation: = (namespaces.streams, "connection-timeout") .. attribute:: HOST_GONE :annotation: = (namespaces.streams, "host-gone") .. attribute:: HOST_UNKNOWN :annotation: = (namespaces.streams, "host-unknown") .. attribute:: IMPROPER_ADDRESSING :annotation: = (namespaces.streams, "improper-addressing") .. attribute:: INTERNAL_SERVER_ERROR :annotation: = (namespaces.streams, "internal-server-error") .. attribute:: INVALID_FROM :annotation: = (namespaces.streams, "invalid-from") .. attribute:: INVALID_NAMESPACE :annotation: = (namespaces.streams, "invalid-namespace") .. attribute:: INVALID_XML :annotation: = (namespaces.streams, "invalid-xml") .. attribute:: NOT_AUTHORIZED :annotation: = (namespaces.streams, "not-authorized") .. attribute:: NOT_WELL_FORMED :annotation: = (namespaces.streams, "not-well-formed") .. attribute:: POLICY_VIOLATION :annotation: = (namespaces.streams, "policy-violation") .. attribute:: REMOTE_CONNECTION_FAILED :annotation: = (namespaces.streams, "remote-connection-failed") .. attribute:: RESET :annotation: = (namespaces.streams, "reset") .. attribute:: RESOURCE_CONSTRAINT :annotation: = (namespaces.streams, "resource-constraint") .. attribute:: RESTRICTED_XML :annotation: = (namespaces.streams, "restricted-xml") .. attribute:: SEE_OTHER_HOST :annotation: = (namespaces.streams, "see-other-host") .. attribute:: SYSTEM_SHUTDOWN :annotation: = (namespaces.streams, "system-shutdown") .. attribute:: UNDEFINED_CONDITION :annotation: = (namespaces.streams, "undefined-condition") .. attribute:: UNSUPPORTED_ENCODING :annotation: = (namespaces.streams, "unsupported-encoding") .. attribute:: UNSUPPORTED_FEATURE :annotation: = (namespaces.streams, "unsupported-feature") .. attribute:: UNSUPPORTED_STANZA_TYPE :annotation: = (namespaces.streams, "unsupported-stanza-type") .. attribute:: UNSUPPORTED_VERSION :annotation: = (namespaces.streams, "unsupported-version") """ BAD_FORMAT = (namespaces.streams, "bad-format") BAD_NAMESPACE_PREFIX = (namespaces.streams, "bad-namespace-prefix") CONFLICT = (namespaces.streams, "conflict") CONNECTION_TIMEOUT = (namespaces.streams, "connection-timeout") HOST_GONE = (namespaces.streams, "host-gone") HOST_UNKNOWN = (namespaces.streams, "host-unknown") IMPROPER_ADDRESSING = (namespaces.streams, "improper-addressing") INTERNAL_SERVER_ERROR = (namespaces.streams, "internal-server-error") INVALID_FROM = (namespaces.streams, "invalid-from") INVALID_NAMESPACE = (namespaces.streams, "invalid-namespace") INVALID_XML = (namespaces.streams, "invalid-xml") NOT_AUTHORIZED = (namespaces.streams, "not-authorized") NOT_WELL_FORMED = (namespaces.streams, "not-well-formed") POLICY_VIOLATION = (namespaces.streams, "policy-violation") REMOTE_CONNECTION_FAILED = (namespaces.streams, "remote-connection-failed") RESET = (namespaces.streams, "reset") RESOURCE_CONSTRAINT = (namespaces.streams, "resource-constraint") RESTRICTED_XML = (namespaces.streams, "restricted-xml") SEE_OTHER_HOST = (namespaces.streams, "see-other-host") SYSTEM_SHUTDOWN = (namespaces.streams, "system-shutdown") UNDEFINED_CONDITION = (namespaces.streams, "undefined-condition") UNSUPPORTED_ENCODING = (namespaces.streams, "unsupported-encoding") UNSUPPORTED_FEATURE = (namespaces.streams, "unsupported-feature") UNSUPPORTED_STANZA_TYPE = (namespaces.streams, "unsupported-stanza-type") UNSUPPORTED_VERSION = (namespaces.streams, "unsupported-version") StreamErrorCondition.SEE_OTHER_HOST.xso_class.new_address = xso.Text() class StreamError(ConnectionError): def __init__(self, condition, text=None): if not isinstance(condition, StreamErrorCondition): condition = StreamErrorCondition(condition) warnings.warn( "as of aioxmpp 1.0, stream error conditions must be members " "of the aioxmpp.errors.StreamErrorCondition enumeration", DeprecationWarning, stacklevel=2, ) super().__init__("stream error: {}".format( format_error_text(condition, text)) ) self.condition = condition self.text = text class StanzaError(Exception): pass class XMPPError(StanzaError): """ Exception representing an error defined in the XMPP protocol. :param condition: The :rfc:`6120` defined error condition as enumeration member or :class:`aioxmpp.xso.XSO` :type condition: :class:`aioxmpp.ErrorCondition` or :class:`aioxmpp.xso.XSO` :param text: Optional human-readable text explaining the error :type text: :class:`str` :param application_defined_condition: Object describing the error in more detail :type application_defined_condition: :class:`aioxmpp.xso.XSO` .. versionchanged:: 0.10 As of 0.10, `condition` should either be a :class:`aioxmpp.ErrorCondition` enumeration member or an XSO representing one of the error conditions. For compatibility, namespace-localpart tuples indicating the tag of the defined error condition are still accepted. .. deprecated:: 0.10 Starting with aioxmpp 1.0, namespace-localpart tuples will not be accepted anymore. See the changelog for notes on the transition. .. attribute:: condition_obj The :class:`aioxmpp.XSO` which represents the error condition. .. versionadded:: 0.10 .. autoattribute:: condition .. attribute:: text Optional human-readable text describing the error further. This is :data:`None` if the text is omitted. .. attribute:: application_defined_condition Optional :class:`aioxmpp.XSO` which further defines the error condition. Relevant subclasses: .. autosummary:: aioxmpp.XMPPAuthError aioxmpp.XMPPModifyError aioxmpp.XMPPCancelError aioxmpp.XMPPContinueError aioxmpp.XMPPWaitError """ TYPE = structs.ErrorType.CANCEL def __init__(self, condition, text=None, application_defined_condition=None): if not isinstance(condition, (ErrorCondition, xso.XSO)): condition = ErrorCondition(condition) warnings.warn( "as of aioxmpp 1.0, error conditions must be members of the " "aioxmpp.ErrorCondition enumeration", DeprecationWarning, stacklevel=2, ) super().__init__(format_error_text( condition.enum_member, text=text, application_defined_condition=application_defined_condition)) self.condition_obj = condition.to_xso() self.text = text self.application_defined_condition = application_defined_condition @property def condition(self): """ :class:`aioxmpp.ErrorCondition` enumeration member representing the error condition. """ return self.condition_obj.enum_member class XMPPWarning(XMPPError, UserWarning): TYPE = structs.ErrorType.CONTINUE class XMPPAuthError(XMPPError, PermissionError): TYPE = structs.ErrorType.AUTH class XMPPModifyError(XMPPError, ValueError): TYPE = structs.ErrorType.MODIFY class XMPPCancelError(XMPPError): TYPE = structs.ErrorType.CANCEL class XMPPWaitError(XMPPError): TYPE = structs.ErrorType.WAIT class XMPPContinueError(XMPPWarning): TYPE = structs.ErrorType.CONTINUE class ErroneousStanza(StanzaError): """ This exception is thrown into listeners for IQ responses by :class:`aioxmpp.stream.StanzaStream` if a response for an IQ was received, but could not be decoded (due to malformed or unsupported payload). .. attribute:: partial_obj Contains the partially decoded stanza XSO. Do not rely on any members except those representing XML attributes (:attr:`~.StanzaBase.to`, :attr:`~.StanzaBase.from_`, :attr:`~.StanzaBase.type_`). """ def __init__(self, partial_obj): super().__init__("erroneous stanza received: {!r}".format( partial_obj)) self.partial_obj = partial_obj class StreamNegotiationFailure(ConnectionError): pass class SecurityNegotiationFailure(StreamNegotiationFailure): def __init__(self, xmpp_error, kind="Security negotiation failure", text=None): msg = "{}: {}".format(kind, xmpp_error) if text: msg += " ('{}')".format(text) super().__init__(msg) self.xmpp_error = xmpp_error self.text = text class SASLUnavailable(SecurityNegotiationFailure): # we use this to tell the Client that SASL has not been available at all, # or that we could not agree on mechanisms. # it might be helpful to notify the peer about this before dying. pass class TLSFailure(SecurityNegotiationFailure): def __init__(self, xmpp_error, text=None): super().__init__(xmpp_error, text=text, kind="TLS failure") class TLSUnavailable(TLSFailure): pass class UserError(Exception): """ An exception subclass, which should be used as a mix-in. It is intended to be used for exceptions which may be user-facing, such as connection errors, value validation issues and the like. `localizable_string` must be a :class:`.i18n.LocalizableString` instance. The `args` and `kwargs` will be passed to :class:`.LocalizableString.localize` when either :func:`str` is called on the :class:`UserError` or :meth:`localize` is called. The :func:`str` is created using the default :class:`~.i18n.LocalizingFormatter` and a :class:`gettext.NullTranslations` instance. The point in time at which the default localizing formatter is created is unspecified. .. automethod:: localize """ DEFAULT_FORMATTER = i18n.LocalizingFormatter() DEFAULT_TRANSLATIONS = gettext.NullTranslations() def __init__(self, localizable_string, *args, **kwargs): super().__init__() self._str = localizable_string.localize( self.DEFAULT_FORMATTER, self.DEFAULT_TRANSLATIONS, *args, **kwargs) self.localizable_string = localizable_string self.args = args self.kwargs = kwargs def __str__(self): return str(self._str) def localize(self, formatter, translator): """ Return a localized version of the `localizable_string` passed to the constructor. It is formatted using the `formatter` with the `args` and `kwargs` passed to the constructor of :class:`UserError`. """ return self.localizable_string.localize( formatter, translator, *self.args, **self.kwargs ) class UserValueError(UserError, ValueError): """ This is a :class:`ValueError` with :class:`UserError` mixed in. """ class MultiOSError(OSError): """ Describe an error situation which has been caused by the sequential occurrence of multiple other `exceptions`. The `message` shall be descriptive and will be prepended to a concatenation of the error messages of the given `exceptions`. """ def __init__(self, message, exceptions): flattened_exceptions = [] for exc in exceptions: if hasattr(exc, "exceptions"): flattened_exceptions.extend(exc.exceptions) else: flattened_exceptions.append(exc) super().__init__( "{}: multiple errors: {}".format( message, ", ".join(map(str, flattened_exceptions)) ) ) self.exceptions = flattened_exceptions class GatherError(RuntimeError): """ Describe an error situation which has been caused by the occurrence of multiple other `exceptions`. The `message` shall be descriptive and will be prepended to a concatenation of the error messages of the given `exceptions`. """ def __init__(self, message, exceptions): flattened_exceptions = [] for exc in exceptions: if hasattr(exc, "exceptions"): flattened_exceptions.extend(exc.exceptions) else: flattened_exceptions.append(exc) super().__init__( "{}: multiple errors: {}".format( message, ", ".join(map(str, flattened_exceptions)) ) ) self.exceptions = flattened_exceptions aioxmpp/forms/000077500000000000000000000000001416014621300136575ustar00rootroot00000000000000aioxmpp/forms/__init__.py000066400000000000000000000127361416014621300160010ustar00rootroot00000000000000######################################################################## # File name: __init__.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## """ :mod:`~aioxmpp.forms` --- Data Forms support (:xep:`4`) ####################################################### This subpackage contains tools to deal with :xep:`4` Data Forms. Data Forms is a pervasive and highly flexible protocol used in XMPP. It allows for machine-readable (and processable) forms as well as tables of data. This flexibility comes unfortunately at the price of complexity. This subpackage attempts to take some of the load of processing Data Forms off the application developer. Cheat Sheet: * The :class:`Form` class exists for use cases where automated processing of Data Forms is supposed to happen. The :ref:`api-aioxmpp.forms-declarative-style` allow convenient access to and manipulation of form data from within code. * Direct use of the :class:`Data` XSO is advisable if you want to present forms or data to sentient beings: even though :class:`Form` is more convenient for machine-to-machine use, using the :class:`Data` sent by the peer easily allows showing the user *all* fields supported by the peer. * For machine-processed tables, there is no tooling (yet). .. versionadded:: 0.7 Even though the :mod:`aioxmpp.forms` module existed pre-0.7, it has not been documented and was thus not part of the public API. .. note:: The authors are not entirely happy with the API at some points. Specifically, at some places where mutable data structures are used, the mutation of these data structures may have unexpected side effects. This may be rectified in a future release by replacing these data structures with their appropriate immutable equivalents. These locations are marked accordingly. Attributes added to stanzas =========================== :mod:`aioxmpp.forms` adds the following attributes to stanzas: .. attribute:: aioxmpp.Message.xep0004_data A sequence of :class:`Data` instances. This is used for example by the :mod:`~.muc` implementation (:xep:`45`). .. versionadded:: 0.8 .. _api-aioxmpp.forms-declarative-style: Declarative-style Forms ======================= Base class ---------- .. autoclass:: Form Fields ------ Text fields ~~~~~~~~~~~ .. autoclass:: TextSingle(var, type_=xso.String(), *[, default=None][, required=False][, desc=None][, label=None]) .. autoclass:: TextPrivate(var, type_=xso.String(), *[, default=None][, required=False][, desc=None][, label=None]) .. autoclass:: TextMulti(var, type_=xso.String(), *[, default=()][, required=False][, desc=None][, label=None]) JID fields ~~~~~~~~~~ .. autoclass:: JIDSingle(var, *[, default=None][, required=False][, desc=None][, label=None]) .. autoclass:: JIDMulti(var, *[, default=()][, required=False][, desc=None][, label=None]) Selection fields ~~~~~~~~~~~~~~~~ .. autoclass:: ListSingle(var, type_=xso.String(), *[, default=None][, options=[]][, required=False][, desc=None][, label=None]) .. autoclass:: ListMulti(var, type_=xso.String(), *[, default=frozenset()][, options=[]][, required=False][, desc=None][, label=None]) Other fields ~~~~~~~~~~~~ .. autoclass:: Boolean(var, *[, default=False][, required=False][, desc=None][, label=None]) Abstract base classes ~~~~~~~~~~~~~~~~~~~~~ .. currentmodule:: aioxmpp.forms.fields .. autoclass:: AbstractField .. autoclass:: AbstractChoiceField(var, type_=xso.String(), *[, options=[]][, required=False][, desc=None][, label=None]) .. currentmodule:: aioxmpp.forms .. _api-aioxmpp.forms-bound-fields: Bound fields ============ Bound fields are objects which are returned when the descriptor attribute is accessed on a form instance. It holds the value of the field, as well as overrides for the default (specified on the descriptors themselves) values for certain attributes (such as :attr:`~.AbstractField.desc`). For the different field types, there are different classes of bound fields, which are documented below. .. currentmodule:: aioxmpp.forms.fields .. autoclass:: BoundField .. autoclass:: BoundSingleValueField .. autoclass:: BoundMultiValueField .. autoclass:: BoundOptionsField .. autoclass:: BoundSelectField .. autoclass:: BoundMultiSelectField .. currentmodule:: aioxmpp.forms XSOs ==== .. autoclass:: Data .. autoclass:: DataType .. autoclass:: Field .. autoclass:: FieldType Report and table support ------------------------ .. autoclass:: Reported .. autoclass:: Item """ # NOQA: E501 from . import xso # NOQA: F401 from .xso import ( # NOQA: F401 Data, DataType, Field, FieldType, Reported, Item, ) from .fields import ( # NOQA: F401 Boolean, ListSingle, ListMulti, JIDSingle, JIDMulti, TextSingle, TextMulti, TextPrivate, ) from .form import ( # NOQA: F401 Form, ) aioxmpp/forms/fields.py000066400000000000000000001055211416014621300155030ustar00rootroot00000000000000######################################################################## # File name: fields.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import abc import collections import copy import aioxmpp.xso as xso from . import xso as forms_xso descriptor_ns = "{jabber:x:data}field" FIELD_DOCSTRING_TEMPLATE = """ ``{field_type.value}`` field ``{var}`` {label} """ class BoundField(metaclass=abc.ABCMeta): """ Abstract base class for objects returned by the field descriptors. :param field: field descriptor to bind :type field: :class:`AbstractField` :param instance: form instance to bind to :type instance: :class:`object` :class:`BoundField` instances represent the connection between the field descriptors present at a form *class* and the *instance* of that class. They store of course the value of the field for the specific instance, but also possible instance-specific overrides for the metadata attributes :attr:`desc`, :attr:`label` and :attr:`required` (and possibly :attr:`.BoundOptionsField.options`). By default, these attributes return the same value as set on the corresponding `field`, but the attributes can be set to different values, which only affects the single form instance. The use case is to fill these fields with the information obtained from a :class:`.Data` XSO when creating a form with :meth:`.Form.from_xso`: it allows the fields to behave exactly like the sender specified. Deep-copying a :class:`BoundField` deepcopies all attributes, except the :attr:`field` and :attr:`instance` attributes. See also :meth:`clone_for` for copying a bound field for a new `instance`. Subclass overview: .. autosummary:: BoundSingleValueField BoundMultiValueField BoundSelectField BoundMultiSelectField Binding relationship: .. autoattribute:: field .. attribute:: instance The `instance` as passed to the constructor. Field metadata attributes: .. autoattribute:: desc .. autoattribute:: label .. autoattribute:: required Helper methods: .. automethod:: clone_for The following methods must be implemented by base classes. .. automethod:: load .. automethod:: render """ def __init__(self, field, instance): super().__init__() self._field = field self.instance = instance @property def field(self): """ The field which is bound to the :attr:`instance`. """ return self._field def __repr__(self): return "".format( self._field, self.instance, id(self), ) def __deepcopy__(self, memo): result = copy.copy(self) for k, v in self.__dict__.items(): if k == "_field" or k == "instance": continue setattr(result, k, copy.deepcopy(v, memo)) return result @property def desc(self): """ .. seealso:: :attr:`.Field.desc` for a full description of the ``desc`` semantics. """ try: return self._desc except AttributeError: return self._field.desc @desc.setter def desc(self, value): self._desc = value @property def label(self): """ .. seealso:: :attr:`.Field.label` for a full description of the ``label`` semantics. """ try: return self._label except AttributeError: return self._field.label @label.setter def label(self, value): self._label = value @property def required(self): """ .. seealso:: :attr:`.Field.required` for a full description of the ``required`` semantics. """ try: return self._required except AttributeError: return self._field.required @required.setter def required(self, value): self._required = value def clone_for(self, other_instance, memo=None): """ Clone this bound field for another instance, possibly during a :func:`~copy.deepcopy` operation. :param other_instance: Another form instance to which the newly created bound field shall be bound. :type other_instance: :class:`object` :param memo: Optional deepcopy-memo (see :mod:`copy` for details) If this is called during a deepcopy operation, passing the `memo` helps preserving and preventing loops. This method is essentially a deepcopy-operation, with a modification of the :attr:`instance` afterwards. """ if memo is None: result = copy.deepcopy(self) else: result = copy.deepcopy(self, memo) result.instance = other_instance return result @abc.abstractmethod def load(self, field_xso): """ Load the field information from a data field. :param field_xso: XSO describing the field. :type field_xso: :class:`~.Field` This loads the current value, description, label and possibly options from the `field_xso`, shadowing the information from the declaration of the field on the class. This method is must be overridden and is thus marked abstract. However, when called from a subclass, it loads the :attr:`desc`, :attr:`label` and :attr:`required` from the given `field_xso`. Subclasses are supposed to implement a mechanism to load options and/or values from the `field_xso` and then call this implementation through :func:`super`. """ if field_xso.desc: self._desc = field_xso.desc if field_xso.label: self._label = field_xso.label self._required = field_xso.required @abc.abstractmethod def render(self, *, use_local_metadata=True): """ Return a :class:`~.Field` containing the values and metadata set in the field. :param use_local_metadata: if true, the description, label and required metadata can be sourced from the field descriptor associated with this bound field. :type use_local_metadata: :class:`bool` :return: A new :class:`~.Field` instance. The returned object uses the values accessible through this object; that means, any values set for e.g. :attr:`desc` take precedence over the values declared at the class level. If `use_local_metadata` is false, values declared at the class level are not used if no local values are declared. This is useful when generating a reply to a form received by a peer, as it avoids sending a modified form. This method is must be overridden and is thus marked abstract. However, when called from a subclass, it creates the :class:`~.Field` instance and initialises its :attr:`~.Field.var`, :attr:`~.Field.type_`, :attr:`~.Field.desc`, :attr:`~.Field.required` and :attr:`~.Field.label` attributes and returns the result. Subclasses are supposed to override this method, call the base implementation through :func:`super` to obtain the :class:`~.Field` instance and then fill in the values and/or options. """ result = forms_xso.Field( var=self.field.var, type_=self.field.FIELD_TYPE, ) if use_local_metadata: result.desc = self.desc result.label = self.label result.required = self.required else: try: result.desc = self._desc except AttributeError: pass try: result.label = self._label except AttributeError: pass try: result.required = self._required except AttributeError: pass return result class BoundSingleValueField(BoundField): """ A bound field which has only a single value at any time. Only the first value is parsed when loading data from a :class:`~.Field` XSO. When writing data to a :class:`~.Field` XSO, :data:`None` is treated as the absence of any value; every other value is serialised through the :attr:`~.AbstractField.type_` of the field. .. seealso:: :class:`BoundField` for a description of the arguments. This bound field is used by :class:`TextSingle`, :class:`TextPrivate` and :class:`JIDSingle`. .. autoattribute:: value """ @property def value(self): """ The current value of the field. If no value is set when this attribute is accessed for reading, the :meth:`default` of the field is invoked and the result is set and returned as value. Only values which pass through :meth:`~.AbstractCDataType.coerce` of the :attr:`~.AbstractField.type_` of the field can be set. To revert the :attr:`value` to its default, use the ``del`` operator. """ try: return self._value except AttributeError: # call through to field self._value = self._field.default() return self._value @value.setter def value(self, value): self._value = self._field.type_.coerce(value) @value.deleter def value(self): try: del self._value except AttributeError: pass def load(self, field_xso): try: value = field_xso.values[0] except IndexError: value = self._field.default() else: value = self._field.type_.parse(value) self._value = value super().load(field_xso) def render(self, **kwargs): result = super().render(**kwargs) try: value = self._value except AttributeError: value = self._field.default() if value is None: return result result.values[:] = [ self.field.type_.format(value) ] return result class BoundMultiValueField(BoundField): """ A bound field which can have multiple values. .. seealso:: :class:`BoundField` for a description of the arguments. This bound field is used by :class:`TextMulti` and :class:`JIDMulti`. .. autoattribute:: value """ @property def value(self): """ A tuple of values. This attribute can be set with any iterable; the iterable is then evaluated into a tuple and stored at the bound field. Whenever values are written to this attribute, they are passed through the :meth:`~.AbstractCDataType.coerce` method of the :attr:`~.AbstractField.type_` of the field. To revert the :attr:`value` to its default, use the ``del`` operator. """ try: return self._value except AttributeError: self.value = self._field.default() return self._value @value.setter def value(self, values): coerce = self._field.type_.coerce self._value = tuple( coerce(v) for v in values ) @value.deleter def value(self): try: del self._value except AttributeError: pass def load(self, field_xso): self._value = tuple( self._field.type_.parse(v) for v in field_xso.values ) super().load(field_xso) def render(self, **kwargs): result = super().render(**kwargs) result.values[:] = ( self.field.type_.format(v) for v in self._value ) return result class BoundOptionsField(BoundField): """ This is an intermediate base class used to implement bound fields for fields which have options from which one or more values must be chosen. .. seealso:: :class:`BoundField` for a description of the arguments. When the field is loaded from a :class:`~.Field` XSO, the options are also loaded from there and thus shadow the options defined at the `field`. This may come to a surprise of code expecting a specific set of options. Subclass overview: .. autosummary:: BoundSelectField BoundMultiSelectField .. autoattribute:: options """ @property def options(self): """ This is a :class:`collections.OrderedDict` which maps option keys to their labels. The keys are used as the values of the field; the labels are human-readable text for display. This attribute can be written with any object which is compatible with the dict-constructor. The order is preserved if a sequence of key-value pairs is used. When writing the attribute, the keys are checked against the :meth:`~.AbstractCDataType.coerce` method of the :attr:`~.AbstractField.type_` of the field. To make the :attr:`options` attribute identical to the :attr:`~.AbstractField.options` attribute, use the ``del`` operator. .. warning:: This attribute is mutable, however, mutating it directly may have unexpected side effects: * If the attribute has not been set before, you will actually be mutating the :class:`~.AbstractChoiceField.options` attributes value. This may be changed in the future by copying more eagerly. * The type checking cannot take place when keys are added by direct mutation of the dictionary. This means that errors will be delayed until the actual serialisation of the data form, which may be a confusing thing to debug. Relying on the above behaviour or any other behaviour induced by directly mutating the value returned by this attribute is **not recommended**. Changes to this behaviour are *not* considered breaking changes and will be done without the usual deprecation. """ try: return self._options except AttributeError: return self.field.options @options.setter def options(self, value): iterator = (value.items() if isinstance(value, collections.abc.Mapping) else value) self._options = collections.OrderedDict( (self.field.type_.coerce(k), v) for k, v in iterator ) @options.deleter def options(self): try: del self._options except AttributeError: pass def load(self, field_xso): self._options = collections.OrderedDict( field_xso.options ) super().load(field_xso) def render(self, **kwargs): format_ = self._field.type_.format field_xso = super().render(**kwargs) field_xso.options.update( (format_(k), v) for k, v in self.options.items() ) return field_xso class BoundSelectField(BoundOptionsField): """ Bound field carrying one value out of a set of options. .. seealso:: :class:`BoundField` for a description of the arguments. :attr:`BoundOptionsField.options` for semantics and behaviour of the ``options`` attribute .. autoattribute:: value """ @property def value(self): """ The current value of the field. If no value is set when this attribute is accessed for reading, the :meth:`default` of the field is invoked and the result is set and returned as value. Only values contained in the :attr:`~.BoundOptionsField.options` can be set, other values are rejected with a :class:`ValueError`. To revert the value to the default value specified in the descriptor, use the ``del`` operator. """ try: return self._value except AttributeError: self._value = self.field.default() return self._value @value.setter def value(self, value): options = self.options if value not in options: raise ValueError("{!r} not in field options: {!r}".format( value, tuple(options.keys()), )) self._value = value @value.deleter def value(self): try: del self._value except AttributeError: pass def load(self, field_xso): try: value = field_xso.values[0] except IndexError: try: del self._value except AttributeError: pass else: self._value = self.field.type_.parse(value) super().load(field_xso) def render(self, **kwargs): format_ = self._field.type_.format field_xso = super().render(**kwargs) value = self.value if value is not None: field_xso.values[:] = [format_(value)] return field_xso class BoundMultiSelectField(BoundOptionsField): """ Bound field carrying a subset of values out of a set of options. .. seealso:: :class:`BoundField` for a description of the arguments. :attr:`BoundOptionsField.options` for semantics and behaviour of the ``options`` attribute .. autoattribute:: value """ @property def value(self): """ A :class:`frozenset` whose elements are a subset of the keys of the :attr:`~.BoundOptionsField.options` mapping. This value can be written with any iterable; the iterable is then evaluated into a :class:`frozenset`. If it contains any value not contained in the set of keys of options, the attribute is not written and :class:`ValueError` is raised. To revert the value to the default specified by the field descriptor, use the ``del`` operator. """ try: return self._value except AttributeError: self.value = self.field.default() return self._value @value.setter def value(self, values): new_values = frozenset(values) options = set(self.options.keys()) invalid = new_values - options if invalid: raise ValueError( "{!r} not in field options: {!r}".format( next(iter(invalid)), tuple(self.options.keys()) ) ) self._value = new_values @value.deleter def value(self): try: del self._value except AttributeError: pass def load(self, field_xso): self._value = frozenset( self.field.type_.parse(value) for value in field_xso.values ) super().load(field_xso) def render(self, **kwargs): format_ = self.field.type_.format result = super().render(**kwargs) result.values[:] = [ format_(value) for value in self.value ] return result class AbstractDescriptor(metaclass=abc.ABCMeta): attribute_name = None root_class = None @abc.abstractmethod def descriptor_keys(self): """ Return an iterator with the descriptor keys for this descriptor. The keys will be added to the :attr:`DescriptorClass.DESCRIPTOR_KEYS` mapping, pointing to the descriptor. Duplicate keys will lead to a :class:`TypeError` being raised during declaration of the class. """ class AbstractField(AbstractDescriptor): """ Abstract base class to implement field descriptor classes. :param var: The field ``var`` attribute this descriptor is supposed to represent. :type var: :class:`str` :param type_: The type of the data, defaults to :class:`~.xso.String`. :type type_: :class:`~.xso.AbstractCDataType` :param required: Flag to indicate that the field is required. :type required: :class:`bool` :param desc: Description text for the field, e.g. for tool-tips. :type desc: :class:`str`, without newlines :param label: Short, human-readable label for the field :type label: :class:`str` The arguments are used to initialise the respective attributes. Details on the semantics can be found in the respective documentation pieces below. .. autoattribute:: desc .. attribute:: label Represents the label flag as specified per :xep:`4`. The value of this attribute is used when forms are generated locally. When forms are received from remote peers and :class:`~.Form` instances are constructed from that data, this attribute is not used when rendering a reply or when the value is accessed through the bound field. .. seealso:: :attr:`~.Field.label` for details on the semantics of this attribute .. attribute:: required Represents the required flag as specified per :xep:`4`. The value of this attribute is used when forms are generated locally. When forms are received from remote peers and :class:`~.Form` instances are constructed from that data, this attribute is not used when rendering a reply or when the value is accessed through the bound field. .. seealso:: :attr:`~.Field.required` for details on the semantics of this attribute .. autoattribute:: var .. automethod:: create_bound .. automethod:: default .. automethod:: make_bound """ def __init__(self, var, type_, *, required=False, desc=None, label=None): super().__init__() self._var = var self.required = required self.desc = desc self.label = label self._type = type_ self.__doc__ = FIELD_DOCSTRING_TEMPLATE.format( field_type=self.FIELD_TYPE, var=self.var, desc=self.desc, label=self.label, ) def descriptor_keys(self): yield descriptor_ns, self._var @property def type_(self): """ :class:`.AbstractCDataType` instance used to parse, validate and format the value(s) of this field. The type of a field cannot be changed after its initialisation. """ return self._type @property def desc(self): """ Represents the description as specified per :xep:`4`. The value of this attribute is used when forms are generated locally. When forms are received from remote peers and :class:`~.Form` instances are constructed from that data, this attribute is not used when rendering a reply or when the value is accessed through the bound field. .. seealso:: :attr:`~.Field.desc` for details on the semantics of this attribute """ return self._desc @desc.setter def desc(self, value): if value is not None and any(ch == "\r" or ch == "\n" for ch in value): raise ValueError("desc must not contain newlines") self._desc = value @desc.deleter def desc(self): self._desc = None @property def var(self): """ Represents the field ID as specified per :xep:`4`. The value of this attribute is used to match fields when instantiating :class:`~.Form` classes from :class:`~.Data` XSOs. .. seealso:: :attr:`~.Field.var` for details on the semantics of this attribute """ return self._var @abc.abstractmethod def default(self): """ Create and return a default value for this field. This must be implemented by subclasses. """ @abc.abstractmethod def create_bound(self, for_instance): """ Create a :ref:`bound field class ` instance for this field for the given form object and return it. :param for_instance: The form instance to which the bound field should be bound. This method must be re-implemented by subclasses. .. seealso:: :meth:`make_bound` creates (using this method) or returns an existing bound field for a given form instance. """ def make_bound(self, for_instance): """ Create a new :ref:`bound field class ` or return an existing one for the given form object. :param for_instance: The form instance to which the bound field should be bound. If no bound field can be found on the given `for_instance` for this field, a new one is created using :meth:`create_bound`, stored at the instance and returned. Otherwise, the existing instance is returned. .. seealso:: :meth:`create_bound` creates a new bound field for the given form instance (without storing it anywhere). """ try: return for_instance._descriptor_data[self] except KeyError: bound = self.create_bound(for_instance) for_instance._descriptor_data[self] = bound return bound def __get__(self, instance, type_): if instance is None: return self return self.make_bound(instance) class TextSingle(AbstractField): """ Represent a ``"text-single"`` input with the given `var`. :param default: A default value to initialise the field. .. seealso:: :class:`~.fields.BoundSingleValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractField` for documentation on the `var`, `type_`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.TEXT_SINGLE def __init__(self, var, type_=xso.String(), *, default=None, **kwargs): super().__init__(var, type_, **kwargs) self._default = default def default(self): return self._default def create_bound(self, for_instance): return BoundSingleValueField( self, for_instance, ) class JIDSingle(AbstractField): """ Represent a ``"jid-single"`` input with the given `var`. :param default: A default value to initialise the field. .. seealso:: :class:`~.fields.BoundSingleValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractField` for documentation on the `var`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.JID_SINGLE def __init__(self, var, *, default=None, **kwargs): super().__init__(var, type_=xso.JID(), **kwargs) self._default = default def default(self): return self._default def create_bound(self, for_instance): return BoundSingleValueField( self, for_instance, ) class Boolean(AbstractField): """ Represent a ``"boolean"`` input with the given `var`. :param default: A default value to initialise the field. .. seealso:: :class:`~.fields.BoundSingleValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractField` for documentation on the `var`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.BOOLEAN def __init__(self, var, *, default=False, **kwargs): super().__init__(var, xso.Bool(), **kwargs) self._default = default def default(self): return self._default def create_bound(self, for_instance): return BoundSingleValueField( self, for_instance, ) class TextPrivate(TextSingle): """ Represent a ``"text-private"`` input with the given `var`. :param default: A default value to initialise the field. .. seealso:: :class:`~.fields.BoundSingleValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractField` for documentation on the `var`, `type_`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.TEXT_PRIVATE class TextMulti(AbstractField): """ Represent a ``"text-multi"`` input with the given `var`. :param default: A default value to initialise the field. :type default: :class:`tuple` .. seealso:: :class:`~.fields.BoundMultiValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractField` for documentation on the `var`, `type_`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.TEXT_MULTI def __init__(self, var, type_=xso.String(), *, default=(), **kwargs): super().__init__(var, type_, **kwargs) self._default = default def create_bound(self, for_instance): return BoundMultiValueField(self, for_instance) def default(self): return self._default class JIDMulti(AbstractField): """ Represent a ``"jid-multi"`` input with the given `var`. :param default: A default value to initialise the field. :type default: :class:`tuple` .. seealso:: :class:`~.fields.BoundMultiValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractField` for documentation on the `var`, `type_`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.JID_MULTI def __init__(self, var, *, default=(), **kwargs): super().__init__(var, xso.JID(), **kwargs) self._default = default def create_bound(self, for_instance): return BoundMultiValueField(self, for_instance) def default(self): return self._default class AbstractChoiceField(AbstractField): """ Abstract base class to implement field descriptor classes using options. :param type_: Type used for the option keys. :type type_: :class:`~.xso.AbstractCDataType` :param options: A sequence of key-value pairs or a mapping object representing the options available. :type options: sequence of pairs or mapping The keys of the `options` mapping (or the first elements in the pairs in the sequence of pairs) must be compatible with `type_`, in the sense that must pass through :meth:`~.xso.AbstractCDataType.coerce` (this is enforced when the field is instantiated). Fields using this base class: .. autosummary:: aioxmpp.forms.ListSingle aioxmpp.forms.ListMulti .. seealso:: :class:`~.fields.BoundOptionsField` is an abstract base class to implement :ref:`bound field classes ` for fields inheriting from this class. :class:`~.fields.AbstractField` for documentation on the `var`, `required`, `desc` and `label` arguments. """ def __init__(self, var, *, type_=xso.String(), options=[], **kwargs): super().__init__(var, type_, **kwargs) iterator = (options.items() if isinstance(options, collections.abc.Mapping) else options) self.options = collections.OrderedDict( (type_.coerce(k), v) for k, v in iterator ) self._type = type_ @property def type_(self): return self._type class ListSingle(AbstractChoiceField): """ Represent a ``"list-single"`` input with the given `var`. :param default: A default value to initialise the field. This must be a member of the `options`. .. seealso:: :class:`~.fields.BoundMultiValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractChoiceField` for documentation on the `options` argument. :class:`~.fields.AbstractField` for documentation on the `var`, `type_`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.LIST_SINGLE def __init__(self, var, *, default=None, **kwargs): super().__init__(var, **kwargs) if default is not None and default not in self.options: raise ValueError("invalid default: not in options") self._default = default def create_bound(self, for_instance): return BoundSelectField(self, for_instance) def default(self): return self._default class ListMulti(AbstractChoiceField): """ Represent a ``"list-multi"`` input with the given `var`. :param default: An iterable of `options` keys :type default: iterable `default` is evaluated into a :class:`frozenset` and all elements must be keys of the `options` mapping argument. .. seealso:: :class:`~.fields.BoundMultiValueField` is the :ref:`bound field class ` used by fields of this type. :class:`~.fields.AbstractChoiceField` for documentation on the `options` argument. :class:`~.fields.AbstractField` for documentation on the `var`, `type_`, `required`, `desc` and `label` arguments. """ FIELD_TYPE = forms_xso.FieldType.LIST_MULTI def __init__(self, var, *, default=frozenset(), **kwargs): super().__init__(var, **kwargs) self._default = frozenset(default) if any(value not in self.options for value in self._default): raise ValueError( "invalid default: not in options" ) def create_bound(self, for_instance): return BoundMultiSelectField(self, for_instance) def default(self): return self._default aioxmpp/forms/form.py000066400000000000000000000376231416014621300152070ustar00rootroot00000000000000######################################################################## # 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.py000066400000000000000000000460641416014621300150540ustar00rootroot00000000000000######################################################################## # File name: xso.py # This file is part of: aioxmpp # # LICENSE # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this program. If not, see # . # ######################################################################## import collections import enum import aioxmpp import aioxmpp.xso as xso from aioxmpp.utils import namespaces namespaces.xep0004_data = "jabber:x:data" class Value(xso.XSO): TAG = (namespaces.xep0004_data, "value") value = xso.Text(default="") class ValueElement(xso.AbstractElementType): def unpack(self, item): return item.value def pack(self, value): v = Value() v.value = value return v def get_xso_types(self): return [Value] class Option(xso.XSO): TAG = (namespaces.xep0004_data, "option") label = xso.Attr( tag="label", default=None, ) value = xso.ChildText( (namespaces.xep0004_data, "value"), default=None, ) def validate(self): if self.value is None: raise ValueError("option is missing a value") class OptionElement(xso.AbstractElementType): def unpack(self, item): return (item.value, item.label) def pack(self, value): value, label = value o = Option() o.value = value o.label = label return o def get_xso_types(self): return [Option] class FieldType(enum.Enum): """ Enumeration containing the field types defined in :xep:`4`. .. seealso:: :attr:`Field.values` for important information regarding typing, restrictions, validation and constraints of values in that attribute. Each type has the following attributes and methods: .. automethod:: allow_upcast .. autoattribute:: has_options .. autoattribute:: is_multivalued Quotations in the following attribute descriptions are from said XEP. .. attribute:: BOOLEAN The ``"boolean"`` field: The field enables an entity to gather or provide an either-or choice between two options. The default value is "false". The :attr:`Field.values` sequence should contain zero or one elements. If it contains an element, it must be ``"0"``, ``"1"``, ``"false"``, or ``"true"``, in accordance with the XML Schema documents. .. attribute:: FIXED The ``"fixed"`` field: The field is intended for data description (e.g., human-readable text such as "section" headers) rather than data gathering or provision. The child SHOULD NOT contain newlines (the ``\\n`` and ``\\r`` characters); instead an application SHOULD generate multiple fixed fields, each with one child. As such, the :attr:`Field.values` sequence should contain exactly one element. :attr:`Field.desc`, :attr:`Field.label`, :attr:`Field.options` and :attr:`Field.var` should be set to :data:`None` or empty containers. .. attribute:: HIDDEN The ``"hidden"`` field: The field is not shown to the form-submitting entity, but instead is returned with the form. The form-submitting entity SHOULD NOT modify the value of a hidden field, but MAY do so if such behavior is defined for the "using protocol". This type is commonly used for the ``var="FORM_TYPE"`` field, as specified in :xep:`68`. .. attribute:: JID_MULTI The ``"jid-multi"`` field: The field enables an entity to gather or provide multiple Jabber IDs. Each provided JID SHOULD be unique (as determined by comparison that includes application of the Nodeprep, Nameprep, and Resourceprep profiles of Stringprep as specified in XMPP Core), and duplicate JIDs MUST be ignored. As such, the :attr:`Field.values` sequence should contain zero or more strings representing Jabber IDs. :attr:`Field.options` should be empty. .. attribute:: JID_SINGLE The ``"jid-single"`` field: The field enables an entity to gather or provide a single Jabber ID. As such, the :attr:`Field.values` sequence should contain zero or one string representing a Jabber ID. :attr:`Field.options` should be empty. .. attribute:: LIST_MULTI The ``"list-multi"`` field: The field enables an entity to gather or provide one or more options from among many. A form-submitting entity chooses one or more items from among the options presented by the form-processing entity and MUST NOT insert new options. The form-submitting entity MUST NOT modify the order of items as received from the form-processing entity, since the order of items MAY be significant. Thus, :attr:`Field.values` should contain a subset of the keys of the :class:`Field.options` dictionary. .. attribute:: LIST_SINGLE The ``"list-single"`` field: The field enables an entity to gather or provide one option from among many. A form-submitting entity chooses one item from among the options presented by the form-processing entity and MUST NOT insert new options. Thus, :attr:`Field.values` should contain a zero or one of the keys of the :class:`Field.options` dictionary. .. attribute:: TEXT_MULTI The ``"text-multi"`` field: The field enables an entity to gather or provide multiple lines of text. Each string in the :attr:`Field.values` attribute should be a single line of text. Newlines are not allowed in data forms fields (due to the ambiguity between ``\\r`` and ``\\n`` and combinations thereof), which is why the text is split on the line endings. .. attribute:: TEXT_PRIVATE The ``"text-private"`` field: The field enables an entity to gather or provide a single line or word of text, which shall be obscured in an interface (e.g., with multiple instances of the asterisk character). The :attr:`Field.values` attribute should contain zero or one string without any newlines. .. attribute:: TEXT_SINGLE The ``"text-single"`` field: The field enables an entity to gather or provide a single line or word of text, which may be shown in an interface. This field type is the default and MUST be assumed if a form-submitting entity receives a field type it does not understand. The :attr:`Field.values` attribute should contain zero or one string without any newlines. """ FIXED = "fixed" HIDDEN = "hidden" BOOLEAN = "boolean" TEXT_SINGLE = "text-single" TEXT_MULTI = "text-multi" TEXT_PRIVATE = "text-private" LIST_SINGLE = "list-single" LIST_MULTI = "list-multi" JID_SINGLE = "jid-single" JID_MULTI = "jid-multi" @property def has_options(self): """ true for the ``list-`` field types, false otherwise. """ return self.value.startswith("list-") @property def is_multivalued(self): """ true for the ``-multi`` field types, false otherwise. """ return self.value.endswith("-multi") def allow_upcast(self, to): """ Return true if the field type may be upcast to the other field type `to`. This relation specifies when it is safe to transfer data from this field type to the given other field type `to`. This is the case if any of the following holds true: * `to` is equal to this type * this type is :attr:`TEXT_SINGLE` and `to` is :attr:`TEXT_PRIVATE` """ if self == to: return True if self == FieldType.TEXT_SINGLE and to == FieldType.TEXT_PRIVATE: return True return False class Field(xso.XSO): """ Represent a single field in a Data Form. :param type_: Field type, must be one of the valid field types specified in :xep:`4`. :type type_: :class:`FieldType` :param options: A mapping of values to labels defining the options in a ``list-*`` field. :type options: :class:`dict` mapping :class:`str` to :class:`str` :param values: A sequence of values currently given for the field. Having more than one value is only valid in ``*-multi`` fields. :type values: :class:`list` of :class:`str` :param desc: Description which can be shown in a tool-tip or similar, without newlines. :type desc: :class:`str` or :data:`None` :param label: Human-readable label to be shown next to the field input :type label: :class:`str` or :data:`None` :param required: Flag to indicate that the field is required :type required: :class:`bool` :param var: "ID" identifying the field uniquely inside the form. Only required for fields carrying a meaning (thus, not for ``fixed``). :type var: :class:`str` or :data:`None` The semantics of a :class:`Field` are different depending on where it occurs: in a :class:`Data`, it is a form field to be filled in, in a :class:`Item` it is a cell of a row and in a :class:`Reported` it represents a column header. .. attribute:: required A boolean flag indicating whether the field is required. If true, the XML serialisation will contain the corresponding ```` tag. .. attribute:: desc Single line of description for the field. This attribute represents the ```` element from :xep:`4`. .. attribute:: values A sequence of strings representing the ```` elements of the field, one string for each value. .. note:: Since the requirements on the sequence of strings in :attr:`values` change depending on the :attr:`type_` attribute, validation and type conversion on assignment is very lax. The attribute accepts all sequences of strings, even if the field is for example a :attr:`FieldType.BOOLEAN` field, which allows for at most one string of a well-defined format (see the documentation there for the details). This makes it easy to inadvertendly generate invalid forms, which is why you should be using :class:`Form` subclasses when accessing forms from within normal code and some other, generic mechanism taking care of these details when showing forms in a UI framework to users. Note that devising such a mechanism is out of scope for :mod:`aioxmpp`, as every UI framework has different requirements. .. attribute:: options A dictionary mapping values to human-readable labels, representing the ``