pax_global_header00006660000000000000000000000064152621413570014520gustar00rootroot0000000000000052 comment=661625032e94f3d95b5f75d21bf661f270f51699 helmutg-asyncvarlink-185e456/000077500000000000000000000000001526214135700161525ustar00rootroot00000000000000helmutg-asyncvarlink-185e456/.github/000077500000000000000000000000001526214135700175125ustar00rootroot00000000000000helmutg-asyncvarlink-185e456/.github/workflows/000077500000000000000000000000001526214135700215475ustar00rootroot00000000000000helmutg-asyncvarlink-185e456/.github/workflows/ci.yaml000066400000000000000000000071441526214135700230340ustar00rootroot00000000000000name: asyncvarlink on: push: workflow_dispatch: permissions: contents: read pages: write id-token: write jobs: black: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Run black run: | pip install 'black >=25,<26' black --check --diff asyncvarlink black --check --diff tests codespell: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run codespell uses: codespell-project/actions-codespell@v2 mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Run mypy run: | pip install mypy mypy asyncvarlink pylint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Run pylint run: | pip install pylint pylint --fail-under 9 asyncvarlink pytest: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Run pytest run: | pip install pytest pytest-cov .[test] pytest -W error --cov=asyncvarlink --cov-fail-under=75 tests sphinxdoc: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: pip install sphinx - name: Build documentation run: | sphinx-build docs docs/build - name: Upload documentation uses: actions/upload-artifact@v4 with: name: sphinxdoc path: docs/build/ certification: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install varlink-python run: python3 -m pip install varlink - name: Certify server run: | python3 ./tests/test_certification.py --socket ./asock & for i in `seq 0 .1 4.9`; do test -e asock && break; sleep $i; done python3 -m varlink.tests.test_certification --client --varlink unix:./asock - name: Certify client run: | python3 -m varlink.tests.test_certification --varlink unix:./rsock & for i in `seq 0 .1 4.9`; do test -e rsock && break; sleep $i; done # Presently fails due to https://github.com/varlink/python/issues/54 python3 ./tests/test_certification.py --client --socket ./rsock || true publish: runs-on: ubuntu-latest environment: github-pages needs: sphinxdoc if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Download artifact uses: actions/download-artifact@v4 with: name: sphinxdoc path: sphinxbuild - name: Upload to GitHub Pages uses: actions/upload-pages-artifact@v3 with: path: sphinxbuild - name: Deploy uses: actions/deploy-pages@v4 helmutg-asyncvarlink-185e456/.gitignore000066400000000000000000000001071526214135700201400ustar00rootroot00000000000000.coverage .hypothesis .mypy_cache .pytest_cache __pycache__ docs/build helmutg-asyncvarlink-185e456/HACKING.md000066400000000000000000000032741526214135700175460ustar00rootroot00000000000000# HACKING.md ## Project `asyncvarlink` is a pure‑Python, `asyncio`‑based implementation of the [varlink](https://varlink.org) IPC protocol. Three design choices differentiate it from the reference implementation. - Based on asyncio - varlink interface descriptions are derived from Python type annotations. Interface descriptions are produced rather than consumed. - Even though file descriptor passing is out of scope in principle, it is supported with semantics similar to systemd. ## Toolchain Install development tools using: `pip install -e ".[devel,test]"`. - Python `>=3.11`. - Fully type-annotated. - Build backend: `flit_core` via `pyproject.toml` - Consult the `justfile` or run `just -l` to see common tasks. Code changes must pass `pytest` and `mypy --strict asyncvarlink`. ## Layout - `asyncvarlink/` — the package (public API re‑exported in `__init__.py`) - `tests/` — unit test suite (uses `unittest`, `IsolatedAsyncioTestCase` via `StrictAsyncioTestCase` and `hypothesis`) - `docs/` — Sphinx sources ## Architecture and file descriptor lifetime Refer to `docs/structure.rst`. ## Conventions - **Style:** `black`; `pylint` enabled (`good-names = ["fd"]`). Maximum line length for code is 79. - **Typing:** `mypy --strict` with `explicit-override`. Use `typing.override` (the `asyncvarlink/types.py` shim covers 3.11 until support is dropped). Prefer `collections.abc` generics. - **Docstrings:** every public class/function has a docstring. Methods decorated with `@varlinkmethod` expose their docstrings via introspection. - **Headers:** Every source file must have a `SPDX-License-Identifier`. - **API**: Most of the API is reexported from `asyncvarlink/__init__.py`. helmutg-asyncvarlink-185e456/LICENSE000066400000000000000000000612661526214135700171720ustar00rootroot00000000000000 GNU LIBRARY GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1991 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the library GPL. It is numbered 2 because it goes with version 2 of the ordinary GPL.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Library General Public License, applies to some specially designated Free Software Foundation software, and to any other libraries whose authors decide to use it. You can use it for your libraries, 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 this service 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 make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library, or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link a program with the library, you must provide complete object files to the recipients so that they can relink them with the library, after making changes to the library and recompiling it. And you must show them these terms so they know their rights. Our method of protecting your rights has two steps: (1) copyright the library, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the library. Also, for each distributor's protection, we want to make certain that everyone understands that there is no warranty for this free library. If the library is modified by someone else and passed on, we want its recipients to know that what they have is not the original version, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that companies distributing free software will individually obtain patent licenses, thus in effect transforming the program into proprietary software. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License, which was designed for utility programs. This license, the GNU Library General Public License, applies to certain designated libraries. This license is quite different from the ordinary one; be sure to read it in full, and don't assume that anything in it is the same as in the ordinary license. The reason we have a separate public license for some libraries is that they blur the distinction we usually make between modifying or adding to a program and simply using it. Linking a program with a library, without changing the library, is in some sense simply using the library, and is analogous to running a utility program or application program. However, in a textual and legal sense, the linked executable is a combined work, a derivative of the original library, and the ordinary General Public License treats it as such. Because of this blurred distinction, using the ordinary General Public License for libraries did not effectively promote software sharing, because most developers did not use the libraries. We concluded that weaker conditions might promote sharing better. However, unrestricted linking of non-free programs would deprive the users of those programs of all benefit from the free status of the libraries themselves. This Library General Public License is intended to permit developers of non-free programs to use free libraries, while preserving your freedom as a user of such programs to change the free libraries that are incorporated in them. (We have not seen how to achieve this as regards changes in header files, but we have achieved it as regards changes in the actual functions of the Library.) The hope is that this will lead to faster development of free libraries. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, while the latter only works together with the library. Note that it is possible for a library to be covered by the ordinary General Public License rather than by this special one. GNU LIBRARY GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Library General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also compile or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. c) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. d) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. 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 not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), 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 distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Library 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 specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "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 LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY 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 LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey 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 library is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU Library General Public License along with this library; if not, see . Also add information on how to contact you by electronic and paper mail. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. , 1 April 1990 Moe Ghoul, President of Vice That's all there is to it! helmutg-asyncvarlink-185e456/README.md000066400000000000000000000073231526214135700174360ustar00rootroot00000000000000asyncvarlink ============ This is a pure Python implementation of the [varlink](https://varlink.org) IPC protocol based on `asyncio`. The main differences to he [reference implementation](https://github.com/varlink/python) are: * Usage of `asyncio` instead of synchronous threading * Where the reference implementation parses a varlink interface description as a source of truth, this implementation derives a varlink interface description from a typed Python class to describe an interface. * Even though the [varlink faq](https://varlink.org/FAQ) explicitly renders passing file descriptors out of scope, `systemd` uses it and it is an important feature also implemented here. Client usage ============ The `VarlinkServiceInterface` class represents the `org.varlink.service` introspection interface. We can use it to introspect `systemd-hostnamed`. import asyncio from asyncvarlink import VarlinkClientProtocol, connect_unix_varlink from asyncvarlink.serviceinterface import VarlinkServiceInterface async def main() -> None: t, p = await connect_unix_varlink( VarlinkClientProtocol, "/run/systemd/io.systemd.Hostname" ) i = p.make_proxy(VarlinkServiceInterface) print(await i.GetInfo()) t.close() asyncio.run(main()) Server usage ============ Here is an example for defining an interface. If it is to be used by a server, the methods need to be implemented of course, but on the client side, typed stubs will do. import asyncio import collections.abc import enum from asyncvarlink import VarlinkInterface, varlinkmethod class Direction(enum.Enum): left = "left" right = "right" class DemoInterface(VarlinkInterface, name="com.example.demo"): @varlinkmethod(return_parameter="direction") def Reverse(self, *, value: Direction) -> Direction: return Direction.left if value == Direction.right else Direction.right @varlinkmethod(return_parameter="value") def Range(self, *, count: int) -> collections.abc.Iterator[int]: return iter(range(count)) @varlinkmethod async def Sleep(self, *, delay: float) -> None: await asyncio.sleep(delay) Setting up a service is now a matter of plugging things together. import asyncio import sys from asyncvarlink import VarlinkInterfaceRegistry, VarlinkTransport from asyncvarlink.serviceinterface import VarlinkServiceInterface registry = VarlinkInterfaceRegistry() registry.register_interface( VarlinkServiceInterface( "ExampleVendor", "DemonstrationProduct", "1.0", "https://github.com/helmutg/asyncvarlink", registry, ), ) registry.register_interface(DemoInterface()) VarlinkTransport( asyncio.get_event_loop(), sys.stdin.fileno(), sys.stdout.fileno(), registry.protocol_factory(), ) When communicating via stdio, `varlinkctl` from `systemd` may be used to interact with a service. Registering a `VarlinkServiceInterface` implementing the `org.varlink.service` introspection interface is optional. Documentation ============= The source for sphinxdoc documentation resides below `docs/`. A rendered version is [published on github pages](https://helmutg.github.io/asyncvarlink/). Collaboration ============= The primary means of collaborating on this project is [github](https://github.com/helmutg/asyncvarlink). If you prefer not to use a centralized forge, sending inquiries and patches to [Helmut](mailto:helmut@subdivi.de?Subject=asyncvarlink) is also welcome. Please refer to `HACKING.md` for more information on development processes. License ======= LGPL-2.0-or-later helmutg-asyncvarlink-185e456/asyncvarlink/000077500000000000000000000000001526214135700206565ustar00rootroot00000000000000helmutg-asyncvarlink-185e456/asyncvarlink/__init__.py000066400000000000000000000040531526214135700227710ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """A varlink implementation in pure Python with a few key design choices: * asyncio: There is no synchronous support. * file descriptor passing: Even though the varlink faq says that passing file descriptors is out of scope, systemd does so and this library supports such use. * automatic introspection via type annotations: Rather than having to write a .varlink description file supporting introspection, the interface description is computed from Python type annotations. """ from .clientprotocol import VarlinkClientProtocol, VarlinkInterfaceProxy # Not re-exporting .conversion from .error import ( GenericVarlinkErrorReply, TypedVarlinkErrorReply, VarlinkErrorReply, ) from .interface import ( AnnotatedResult, LastResult, varlinkmethod, varlinksignature, VarlinkMethodSignature, VarlinkInterface, ) from .message import VarlinkMethodCall, VarlinkMethodReply from .protocol import VarlinkBaseProtocol, VarlinkProtocol, VarlinkTransport from .serverprotocol import ( VarlinkInterfaceRegistry, VarlinkServerProtocol, VarlinkInterfaceServerProtocol, ) # Not re-exporting .serviceinterface from .types import FileDescriptor, FileDescriptorArray from .util import ( connect_unix_varlink, create_unix_server, get_listen_fd, VarlinkUnixServer, ) __all__ = [ "AnnotatedResult", "FileDescriptor", "FileDescriptorArray", "GenericVarlinkErrorReply", "LastResult", "TypedVarlinkErrorReply", "VarlinkBaseProtocol", "VarlinkClientProtocol", "VarlinkErrorReply", "VarlinkInterface", "VarlinkInterfaceProxy", "VarlinkInterfaceRegistry", "VarlinkInterfaceServerProtocol", "VarlinkMethodCall", "VarlinkMethodReply", "VarlinkMethodSignature", "VarlinkProtocol", "VarlinkServerProtocol", "VarlinkTransport", "VarlinkUnixServer", "connect_unix_varlink", "create_unix_server", "get_listen_fd", "varlinkmethod", "varlinksignature", ] helmutg-asyncvarlink-185e456/asyncvarlink/clientprotocol.py000066400000000000000000000351611526214135700242760ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """asyncio varlink client protocol implementation""" import asyncio import collections import collections.abc import contextlib import functools import typing from .conversion import FileDescriptorVarlinkType from .interface import ( VarlinkInterface, VarlinkMethodSignature, varlinksignature, ) from .message import VarlinkMethodCall, VarlinkMethodReply from .protocol import VarlinkProtocol from .types import FileDescriptorArray, JSONObject, override from .util import completing_future class VarlinkClientProtocol(VarlinkProtocol): """Protocol class for a varlink client. Calls can be issued using the call and call_more methods. Associated replies are returned or generated from these methods asynchronously. There usually is no need to further derive this class. """ _CallResult: typing.TypeAlias = tuple[ VarlinkMethodReply, FileDescriptorArray | None ] class _PendingReply: def __init__( self, want_fds: bool, consumer_fut: asyncio.Future[None] | None = None, ): loop = asyncio.get_running_loop() self.future: asyncio.Future[ "VarlinkClientProtocol._CallResult" ] = loop.create_future() self.want_fds = want_fds self.consumer_fut = consumer_fut or loop.create_future() def request_received( self, obj: JSONObject, fds: FileDescriptorArray | None ) -> None: if self.future.done(): # Multiple replies not permitted. return try: reply = VarlinkMethodReply.fromjson(obj) except (TypeError, ValueError) as err: self.future.set_exception(err) return if not self.want_fds: fds = None elif fds: fds.reference_until_done(self.consumer_fut) self.future.set_result((reply, fds)) def connection_lost(self, exc: Exception) -> None: if self.future.done(): return self.future.set_exception(exc) def consumer_done(self) -> None: self.consumer_fut.set_result(None) class _PendingReplies: def __init__(self) -> None: self.replies = collections.deque( [VarlinkClientProtocol._PendingReply(True)] ) def request_received( self, obj: JSONObject, fds: FileDescriptorArray | None ) -> None: lastreply = self.replies[-1] if lastreply.future.done(): # This call does not handle any more continuations. return if obj.get("continues", False): self.replies.append(VarlinkClientProtocol._PendingReply(True)) lastreply.request_received(obj, fds) def connection_lost(self, exc: Exception) -> None: lastreply = self.replies[-1] lastreply.connection_lost(exc) def consumer_done(self) -> None: if self.replies: while len(self.replies) > 1: preply = self.replies.popleft() assert preply.future.done() preply.consumer_done() self.replies[0].consumer_done() def __init__(self) -> None: super().__init__() self._pending: collections.deque[ VarlinkClientProtocol._PendingReply | VarlinkClientProtocol._PendingReplies ] = collections.deque() async def call( self, call: VarlinkMethodCall, fds: list[int] | None = None, replyfdsdone: asyncio.Future[None] | None = None, ) -> _CallResult | None: """Issue a varlink call. If the call has the more attribute set, the call_more method must be used instead. The given fds (if any) must remain available until the call method returns and the responsibility for closing them remains with the caller. If the caller expects file descriptors to be returned, it must pass a replyfdsdone future. The returned FileDescriptorArray (if any) will be valid until the replyfdsdone future is done. """ assert not call.more if call.oneway: await self.send_message(call.tojson(), fds, autoclose=False) return None pending = VarlinkClientProtocol._PendingReply( replyfdsdone is not None, replyfdsdone ) try: self._pending.append(pending) # We may pipeline calls here. try: await self.send_message(call.tojson(), fds, autoclose=False) except: # Otherwise, the loop reports an unhandled exception. pending.future.cancel() raise return await pending.future finally: if replyfdsdone is None: pending.consumer_done() async def call_more( self, call: VarlinkMethodCall, fds: list[int] | None = None ) -> collections.abc.AsyncGenerator[_CallResult, None]: """Issue a varlink call expecting multiple replies. If the call does not set the more attribute, the call method must be used instead. The given fds (if any) must remain available until the method returns and the responsibility for closing them remains with the caller. Replies generally include returned a FileDescriptorArray if file descriptors were received. Such an array remains valid from one iteration to the next. Its lifetime can be extended using its reference_until_done method. """ assert call.more pending = VarlinkClientProtocol._PendingReplies() self._pending.append(pending) # We may pipeline calls here. sendfut = self.send_message(call.tojson(), fds, autoclose=False) try: while pending.replies: preply = pending.replies[0] result = await preply.future pending.replies.popleft() try: yield result finally: preply.consumer_done() finally: pending.consumer_done() await sendfut @override def request_received( self, obj: JSONObject, fds: FileDescriptorArray | None ) -> None: if obj.get("continues", False): pending = self._pending[0] else: pending = self._pending.popleft() pending.request_received(obj, fds) @override def error_received( self, err: Exception, _data: bytes, _fds: FileDescriptorArray | None, ) -> None: self.connection_lost(err) @override def connection_lost(self, exc: Exception | None) -> None: super().connection_lost(exc) if exc is None: exc = ConnectionResetError("Connection lost") while self._pending: pending = self._pending.popleft() pending.connection_lost(exc) self.close() @override def eof_received(self) -> None: super().eof_received() self.connection_lost(ConnectionResetError("remote closed connection")) def make_proxy( self, interface: type[VarlinkInterface] ) -> "VarlinkInterfaceProxy": """Convenience function that constructs an interface proxy object for the combination of protocol instance and interface class at hand. """ return VarlinkInterfaceProxy(self, interface) class _ResourceManager: """A context manager that yields a given result on entry and calls a given cleanup function on exit. """ def __init__( self, result: typing.Any, close: collections.abc.Callable[[], None] ): self._result = result self._close = close def __enter__(self) -> typing.Any: return self._result def __exit__(self, *_: object) -> None: self._close() class _ProxiedVarlinkMethodBase: def __init__( self, protocol: VarlinkClientProtocol, interface: type[VarlinkInterface], fqmethod: str, signature: VarlinkMethodSignature, ): self._protocol = protocol self._interface = interface self._fqmethod = fqmethod self._signature = signature @property def oneway(self) -> "_ProxiedVarlinkOnewayMethod": return _ProxiedVarlinkOnewayMethod( self._protocol, self._interface, self._fqmethod, self._signature ) def __call__(self, **kwargs: typing.Any) -> typing.Any: # This really returns either an Awaitable or an AsyncGenerator, but # typing it like that makes using it very annoying. raise NotImplementedError class _ProxiedVarlinkOnewayMethod(_ProxiedVarlinkMethodBase): @property @override def oneway(self) -> typing.Self: return self @override async def __call__(self, **kwargs: typing.Any) -> None: with FileDescriptorArray.new_managed() as pfds: # This may raise a ConversionError parameters = self._signature.parameter_type.tojson( kwargs, {FileDescriptorVarlinkType: pfds} ) with completing_future() as donefut: result = await self._protocol.call( VarlinkMethodCall(self._fqmethod, parameters, oneway=True), [fd.fileno() for fd in pfds], donefut, ) assert result is None class _ProxiedVarlinkMoreMethod(_ProxiedVarlinkMethodBase): @override async def __call__( self, **kwargs: typing.Any ) -> collections.abc.AsyncGenerator[typing.Any, None]: with FileDescriptorArray.new_managed() as pfds: # This may raise a ConversionError. parameters = self._signature.parameter_type.tojson( kwargs, {FileDescriptorVarlinkType: pfds} ) async for reply, rfds in self._protocol.call_more( VarlinkMethodCall(self._fqmethod, parameters, more=True), [fd.fileno() for fd in pfds], ): if reply.error is None: # This may raise a ConversionError. ret = self._signature.return_type.fromjson( reply.parameters, {FileDescriptorVarlinkType: rfds}, ) if self._signature.return_type.contains_fds: if rfds is None: yield contextlib.nullcontext(ret) else: sentinel = object() rfds.reference(sentinel) yield _ResourceManager( ret, functools.partial(rfds.release, sentinel), ) else: yield ret else: self._interface.raise_error(reply) class _ProxiedVarlinkMethod(_ProxiedVarlinkMethodBase): @override async def __call__(self, **kwargs: typing.Any) -> typing.Any: with FileDescriptorArray.new_managed() as pfds: # This may raise a ConversionError parameters = self._signature.parameter_type.tojson( kwargs, {FileDescriptorVarlinkType: pfds} ) with completing_future() as donefut: result = await self._protocol.call( VarlinkMethodCall(self._fqmethod, parameters), [fd.fileno() for fd in pfds], donefut, ) assert result is not None reply, fda = result if reply.error is None: # This may raise a ConversionError. ret = self._signature.return_type.fromjson( reply.parameters, {FileDescriptorVarlinkType: fda} ) if not self._signature.return_type.contains_fds: return ret if fda is None: return contextlib.nullcontext(ret) sentinel = object() fda.reference(sentinel) return _ResourceManager( ret, functools.partial(fda.release, sentinel) ) self._interface.raise_error(reply) class VarlinkInterfaceProxy: """Interface proxy class for a varlink client. Its only purpose is to provide a suitable __getattr__ method such that methods declared on a VarlinkInterface subclass can be invoked on the proxy object and result in calls on a protocol instance. """ def __init__( self, protocol: VarlinkClientProtocol, interface: type[VarlinkInterface], ): """The given protocol instance is used to actually perform calls. A protocol instance can be used with multiple proxy objects concurrently. The interface subclass (not instance) is used to generate conversions for proxy methods. Its methods can be stubbed as they are never called from a proxy. What matters is the types of its methods as they are used for converting between Python and JSON objects. """ self._protocol = protocol self._interface = interface def __getattr__(self, attr: str) -> _ProxiedVarlinkMethodBase: """Look up a method on the VarlinkInterface subclass and return a proxy method if available. Raises AttributeError if no suitable method could be found. The type of the proxy method is dynamic. It is generally asynchronous and must be called within an asyncio event loop. It may or may not return a generator. All arguments must be keyword arguments. If the return type contains file descriptors, the result is additionally wrapped in a synchronous context manager. """ try: fqmethod = f"{self._interface.name}.{attr}" except ValueError as err: raise AttributeError(f"invalid method name {attr}") from err # Intentionally propagate AttributeError method = getattr(self._interface, attr) if (signature := varlinksignature(method)) is None: raise AttributeError(f"no {attr} varlink method found") if signature.more: return _ProxiedVarlinkMoreMethod( self._protocol, self._interface, fqmethod, signature ) return _ProxiedVarlinkMethod( self._protocol, self._interface, fqmethod, signature ) helmutg-asyncvarlink-185e456/asyncvarlink/conversion.py000066400000000000000000000670161526214135700234270ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Helper for converting between Python objects and JSONValues.""" import collections.abc import contextlib import dataclasses import enum import functools import types import typing from .types import ( FileDescriptor, FileDescriptorArray, JSONValue, JSONObject, override, ) class ConversionError(Exception): """A failure to convert a Python value from or to a JSONValue.""" def __init__(self, message: str, location: list[str | int] | None = None): self.message = message self.location: list[str | int] = [] if location is None else location @classmethod def expected( cls, what: str, obj: object, location: list[str | int] | None = None, ) -> "ConversionError": """Construct a Conversion error indicating that something described by what was expected whereas a different type was found. """ return cls( f"expected {what}, but got a {type(obj).__name__}", location ) @classmethod @contextlib.contextmanager def context(cls, where: str | int) -> collections.abc.Iterator[None]: """If a ConversionError passes through this context manager, push the location where onto the location stack as it passes through. """ try: yield except ConversionError as err: err.location.insert(0, where) raise OOBTypeState = collections.abc.Mapping[type["VarlinkType"], typing.Any] | None class VarlinkType: """A type abstraction that is exportable simultaneously to Python type annotations and varlink interface descriptions. """ as_type: typing.Any """A Python type representing the varlink type suitable for a type annotation. """ as_varlink: str """A varlink interface description representation of the varlink type.""" typedefs: dict[str, str] = {} """Varlink type definitions used in the varlink type.""" @functools.cached_property def contains_fds(self) -> bool: """Indicate whether fds requiring lifetime management are contained somewhere inside this type. """ return any( isinstance(vt, FileDescriptorVarlinkType) for vt in self.traverse() ) def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONValue: """Convert a Python object conforming to the as_type type annotation to a json-convertible object suitable for consumption by varlink. A conversion may use the optional out-of-band state object using its own type as key and should otherwise forward the oobstate during recursion. """ raise NotImplementedError def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: """Convert a json-decoded Python object to a Python object conforming to the as_type type annotation. A conversion may use the optional out-of-band state object using its own type as key and should otherwise forward the oobstate during recursion. """ raise NotImplementedError def subtypes(self) -> collections.abc.Iterator["VarlinkType"]: """Return all direct subtypes of this type. Subclasses that nest types should override this method. """ return yield # This is an empty generator by default def traverse(self) -> collections.abc.Iterator["VarlinkType"]: """Recursively traverse the the type into subtypes and yield every used type. """ yield self for vt in self.subtypes(): yield from vt.traverse() @classmethod def from_type_annotation(cls, tobj: typing.Any) -> "VarlinkType": """Convert a Python type annotation object into the VarlinkType abstraction. Note that this conversion is lossy and will convert unknown types to typing.Any/"object". """ origin = typing.get_origin(tobj) args = typing.get_args(tobj) if origin is None: if isinstance(tobj, type): if issubclass(tobj, bool): return SimpleVarlinkType("bool", bool) if issubclass(tobj, int): return SimpleVarlinkType("int", int) if issubclass(tobj, float): return SimpleVarlinkType("float", float, int) if issubclass(tobj, str): return SimpleVarlinkType("string", str) if issubclass(tobj, enum.Enum): return EnumVarlinkType(tobj) if issubclass(tobj, FileDescriptor): return FileDescriptorVarlinkType() if typing.is_typeddict(tobj): # Do not iterate __*_keys__ as their order is unstable. return ObjectVarlinkType( { name: ( cls.from_type_annotation(elemtype).optional() if name in tobj.__optional_keys__ else cls.from_type_annotation(elemtype) ) for name, elemtype in tobj.__annotations__.items() }, ) if dataclasses.is_dataclass(tobj): # is_dataclass also returns True for instances. assert isinstance(tobj, type) return DataclassVarlinkType(tobj) elif origin is typing.Literal: if all(isinstance(arg, str) for arg in args): return LiteralVarlinkType(args) elif origin is typing.Union or origin is types.UnionType: if any(arg is types.NoneType for arg in args): remaining = [alt for alt in args if alt is not types.NoneType] if remaining: if len(remaining) == 1: result = cls.from_type_annotation(remaining[0]) else: result = cls.from_type_annotation( typing.Union[tuple(remaining)] ) if isinstance( result, (ForeignVarlinkType, OptionalVarlinkType) ): return result return OptionalVarlinkType(result) elif origin is list: if len(args) == 1: return ListVarlinkType(cls.from_type_annotation(args[0])) elif origin is dict: if len(args) == 2 and args[0] is str: return DictVarlinkType(cls.from_type_annotation(args[1])) elif origin is set: if len(args) == 1 and args[0] is str: return SetVarlinkType() return ForeignVarlinkType() def optional(self) -> "OptionalVarlinkType": """Wrap the type representation in an OptionalVarlinkType.""" return OptionalVarlinkType(self) def _merge_typedefs( typedefs: dict[str, str], *moretypedefs: dict[str, str] ) -> None: """Update the given typedefs in-place effectively computing a union of all given moretypedefs. Conflicting definitions are rejected with a RuntimeError. """ for defmap in moretypedefs: for name, tdef in defmap.items(): try: olddef = typedefs[name] except KeyError: typedefs[name] = tdef else: if olddef != tdef: raise RuntimeError( "conflicting type definitions for " f"{name}: {olddef} vs {tdef}" ) class SimpleVarlinkType(VarlinkType): """A varlink type representing a base type such as int or str.""" def __init__(self, varlinktype: str, pythontype: type, *convertible: type): self.as_type = pythontype self.as_varlink = varlinktype self._convertible = tuple(convertible) @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONValue: if isinstance(obj, self.as_type): return typing.cast(JSONValue, obj) if isinstance(obj, self._convertible): try: return typing.cast(JSONValue, self.as_type(obj)) except Exception as exc: raise ConversionError( f"expected {self.as_varlink}, but failed to convert from " f"{type(obj).__name__}" ) from exc raise ConversionError.expected(self.as_varlink, obj) @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if isinstance(obj, self.as_type): return obj if isinstance(obj, self._convertible): try: return self.as_type(obj) except Exception as exc: raise ConversionError( f"expected {self.as_varlink}, but failed to convert from " f"{type(obj).__name__}" ) from exc raise ConversionError.expected(self.as_varlink, obj) @override def __repr__(self) -> str: typestr = ", ".join( tobj.__name__ if tobj in {bool, float, int, str} else repr(tobj) for tobj in (self.as_type,) + self._convertible ) return f"{self.__class__.__name__}({self.as_varlink!r}, {typestr})" class OptionalVarlinkType(VarlinkType): """A varlink type that allows an optional null/None value.""" def __init__(self, vtype: VarlinkType): if isinstance(vtype, OptionalVarlinkType): raise RuntimeError("cannot nest OptionalVarlinkTypes") self._vtype = vtype self.as_type = vtype.as_type | None self.as_varlink = "?" + vtype.as_varlink self.typedefs = vtype.typedefs @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONValue: if obj is None: return None return self._vtype.tojson(obj, oobstate) @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if obj is None: return None return self._vtype.fromjson(obj, oobstate) @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self._vtype!r})" @override def subtypes(self) -> collections.abc.Iterator[VarlinkType]: yield self._vtype @override def optional(self) -> typing.Self: return self class ListVarlinkType(VarlinkType): """A varlink type representing a homogeneous array/list value.""" def __init__(self, elttype: VarlinkType): self._elttype = elttype # mypy cannot runtime-constructed type hints. self.as_type = list[elttype.as_type] # type: ignore[name-defined] self.as_varlink = "[]" + elttype.as_varlink self.typedefs = elttype.typedefs @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> list[JSONValue]: if not isinstance(obj, list): raise ConversionError.expected("list", obj) result: list[JSONValue] = [] for elt in obj: with ConversionError.context(len(result)): result.append(self._elttype.tojson(elt, oobstate)) return result @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if not isinstance(obj, list): raise ConversionError.expected("list", obj) result: list[typing.Any] = [] for elt in obj: with ConversionError.context(len(result)): result.append(self._elttype.fromjson(elt, oobstate)) return result @override def subtypes(self) -> collections.abc.Iterator[VarlinkType]: yield self._elttype @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self._elttype!r})" class DictVarlinkType(VarlinkType): """A varlink type representing a map/dict with string keys and homogeneous value types. """ def __init__(self, elttype: VarlinkType): self._elttype = elttype # mypy cannot runtime-constructed type hints. self.as_type = dict[str, elttype.as_type] # type: ignore[name-defined] self.as_varlink = "[string]" + elttype.as_varlink self.typedefs = elttype.typedefs @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONObject: if not isinstance(obj, dict): raise ConversionError.expected("dict", obj) result = {} for key, value in obj.items(): if not isinstance(key, str): raise ConversionError.expected("str as dict key", key) with ConversionError.context(key): result[key] = self._elttype.tojson(value, oobstate) return result @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if not isinstance(obj, dict): raise ConversionError.expected("map", obj) result = {} for key, value in obj.items(): if not isinstance(key, str): raise ConversionError.expected("string as map key", key) with ConversionError.context(key): result[key] = self._elttype.fromjson(value, oobstate) return result @override def subtypes(self) -> collections.abc.Iterator[VarlinkType]: yield self._elttype @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self._elttype!r})" class SetVarlinkType(VarlinkType): """A varlink type that represents a set of strings as a map on the varlink side. """ as_type = set[str] as_varlink = "[string]()" @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONObject: if not isinstance(obj, set): raise ConversionError.expected("set", obj) result: dict[str, JSONValue] = {} for elem in obj: if not isinstance(elem, str): raise ConversionError.expected("str as set element", elem) # Assign an empty struct as value. result[elem] = {} return result @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if not isinstance(obj, dict): raise ConversionError.expected("map", obj) result = set() for key, value in obj.items(): if not isinstance(key, str): raise ConversionError.expected("string as map key", key) if value != {}: raise ConversionError.expected( "empty struct as map value", value, [key] ) result.add(key) return result @override def __repr__(self) -> str: return f"{self.__class__.__name__}()" class ObjectVarlinkType(VarlinkType): """A varlink type representing a map/dict with string keys and inhomogeneous per-key types. On the Python side, this resembles a typing.TypedDict. """ def __init__( self, typemap: dict[str, VarlinkType], *, populatenone: collections.abc.Collection[str] = frozenset(), discardnone: collections.abc.Collection[str] = frozenset(), ): """Construct an representation of a varlink object from a given mapping from keys to type representations. Optionally, two further sets may be given. Fields that are to be found in the populatenone set shall always be emitted as None by fromjson even if missing in the source. In a similar vein, fields that are found in the discardnone set, are removed by fromjson if their value is None. """ names = set(typemap.keys()) if excess := set(populatenone).difference(names): raise ValueError( f"populatenone contains fields {excess} without given a type" ) if excess := set(discardnone).difference(names): raise ValueError( f"discard contains fields {excess} without given a type" ) names = { name for name, tobj in typemap.items() if not isinstance(tobj, OptionalVarlinkType) } if excess := names.intersection(populatenone): raise ValueError( f"non-optional fields {excess} must not be listed in " "populatenone" ) if excess := names.intersection(discardnone): raise ValueError( f"non-optional fields {excess} must not be listed in " "discardnone" ) self._typemap = typemap self._populatenone = populatenone self._discardnone = discardnone # mypy cannot runtime-constructed type hints. self.as_type = typing.TypedDict( # type: ignore[misc] "ObjectVarlinkTypedDict", {name: tobj.as_type for name, tobj in typemap.items()}, ) self.as_varlink = "(%s)" % ", ".join( f"{name}: {tobj.as_varlink}" for name, tobj in typemap.items() ) self.typedefs = {} _merge_typedefs( self.typedefs, *(tobj.typedefs for tobj in typemap.values()) ) @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONObject: if not isinstance(obj, dict): raise ConversionError.expected("dict", obj) result = {} for key, vtype in self._typemap.items(): try: value = obj[key] except KeyError as err: if isinstance(vtype, OptionalVarlinkType): continue raise ConversionError( f"missing required key {key} in given dict", [key] ) from err with ConversionError.context(key): result[key] = vtype.tojson(value, oobstate) for key in obj: if key not in self._typemap: raise ConversionError(f"no type for key {key}", [key]) return result @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if not isinstance(obj, dict): raise ConversionError.expected("map", obj) result: dict[str, typing.Any] = {} for key, vtype in self._typemap.items(): try: value = obj[key] except KeyError as err: if isinstance(vtype, OptionalVarlinkType): if key in self._populatenone: result[key] = None continue raise ConversionError( f"missing required key {key} in given dict", [key] ) from err with ConversionError.context(key): pyvalue = vtype.fromjson(value, oobstate) if pyvalue is not None or key not in self._discardnone: result[key] = pyvalue for key in obj: if key not in self._typemap: raise ConversionError(f"no type for key {key}", [key]) return result @override def subtypes(self) -> collections.abc.Iterator[VarlinkType]: yield from self._typemap.values() @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self._typemap!r})" class DataclassVarlinkType(VarlinkType): """A varlink type representing an object as a dataclasses.dataclass. Only fields with init=True will be considered.""" def __init__(self, dataclasstype: type): assert dataclasses.is_dataclass(dataclasstype) self.as_type = dataclasstype self.as_varlink = dataclasstype.__name__ self._typemap = { field.name: VarlinkType.from_type_annotation(field.type) for field in dataclasses.fields(dataclasstype) if field.init } self.typedefs = {} _merge_typedefs( self.typedefs, *(vtype.typedefs for vtype in self._typemap.values()), { dataclasstype.__name__: "(%s)" % ", ".join( f"{name}: {vtype.as_varlink}" for name, vtype in self._typemap.items() ), }, ) @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONObject: if not dataclasses.is_dataclass(obj): raise ConversionError.expected("a dataclass", obj) result = {} for name, vtype in self._typemap.items(): with ConversionError.context(name): result[name] = vtype.tojson(getattr(obj, name), oobstate) return result @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if not isinstance(obj, dict): raise ConversionError.expected("map", obj) excess_keys = set(obj.keys()) excess_keys.difference_update(self._typemap.keys()) if excess_keys: raise ConversionError( "unexpected dataclass fields " + ", ".join(excess_keys) ) fields = {} for name, vtype in self._typemap.items(): try: value = obj[name] except KeyError as err: if not isinstance(vtype, OptionalVarlinkType): raise ConversionError( f"missing object key {name}" ) from err value = None with ConversionError.context(name): fields[name] = vtype.fromjson(value, oobstate) return self.as_type(**fields) @override def subtypes(self) -> collections.abc.Iterator[VarlinkType]: yield from self._typemap.values() @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self.as_type})" class EnumVarlinkType(VarlinkType): """A varlink type representing an enum as an enum.Enum.""" def __init__(self, enumtype: type[enum.Enum]) -> None: if not issubclass(enumtype, enum.Enum): raise TypeError("a subclass of Enum is required") self.as_type = enumtype self.as_varlink = enumtype.__name__ self.typedefs = { enumtype.__name__: "(%s)" % ", ".join(enumtype.__members__) } @override def tojson(self, obj: typing.Any, oobstate: OOBTypeState = None) -> str: if not isinstance(obj, self.as_type): raise ConversionError.expected(f"enum {self.as_type!r}", obj) assert isinstance(obj, enum.Enum) return obj.name @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if not isinstance(obj, str): raise ConversionError.expected("string as enum value", obj) try: return self.as_type.__members__[obj] except KeyError as err: raise ConversionError( f"enum {self.as_type!r} value {obj!r} not known" ) from err @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self.as_type!r})" class LiteralVarlinkType(VarlinkType): """A varlink type representation for an enum represented as typing.Literal. """ def __init__(self, values: tuple[str, ...]): self._values = values # mypy cannot handle dynamic literals self.as_type = typing.Literal[values] self.as_varlink = "(%s)" % ", ".join(values) @override def tojson(self, obj: typing.Any, oobstate: OOBTypeState = None) -> str: if not (isinstance(obj, str) and obj in self._values): raise ConversionError(f"invalid literal value {obj!r}") return obj @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: if not (isinstance(obj, str) and obj in self._values): raise ConversionError(f"invalid literal value {obj!r}") return obj @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self._values!r})" class FileDescriptorVarlinkType(VarlinkType): """Represent a file descriptor in a varlink message. On the specification side, this use is explicitly ruled out. systemd does it anyway and so does this class. """ as_type = FileDescriptor as_varlink = "int" @classmethod def _get_oob(cls, oobstate: OOBTypeState) -> typing.Any: if oobstate is None: raise ConversionError( "cannot convert a file descriptor without oobstate" ) try: return oobstate[cls] except KeyError: raise ConversionError( "cannot convert a file descriptor without associated oobstate" ) from None @override def tojson(self, obj: typing.Any, oobstate: OOBTypeState = None) -> int: """Represent a file descriptor. It may be conveyed as int | HasFileno. The actual file descriptor is appended to the out-of-band state array and the returned json value is the index into said array. A FileDescriptorArray should be conveyed as out-of-band state. """ if not (isinstance(obj, int) or hasattr(obj, "fileno")): raise ConversionError.expected("int or fileno()-like", obj) fdarray = self._get_oob(oobstate) if not isinstance(fdarray, FileDescriptorArray): raise ConversionError( f"out-of-band state for {self.__class__.__name__} should be " f"FileDescriptorArray, is {type(fdarray)}" ) try: index = fdarray.add(obj) except ValueError as err: raise ConversionError("invalid file descriptor") from err return index @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: """Unrepresent a file descriptor. The int value is understood as an index into the out-of-band state object of type FileDescriptorArray. A file descriptor is looked up at the index and the position is released there. """ if not isinstance(obj, int): raise ConversionError.expected("int", obj) if oobstate is None: raise ConversionError( "cannot unrepresent a file descriptor without oobstate" ) fds = self._get_oob(oobstate) if not isinstance(fds, FileDescriptorArray): raise ConversionError( f"out-of-band state for {self.__class__.__name__} should be " f"FileDescriptorArray, is {type(fds)}" ) try: return fds[obj] except IndexError as err: raise ConversionError( f"attempt to convert invalid file descriptor index {obj}" ) from err class ForeignVarlinkType(VarlinkType): """A varlink type skipping representing a foreign object or typing.Any and skipping value conversion steps. """ as_type = typing.Any def __init__(self, varlink: str = "object"): self.as_varlink = varlink @override def tojson( self, obj: typing.Any, oobstate: OOBTypeState = None ) -> JSONValue: # We have no guarantuee that the object actually is a JSONValue and # hope that the user is doing things correctly. return typing.cast(JSONValue, obj) @override def fromjson( self, obj: JSONValue, oobstate: OOBTypeState = None ) -> typing.Any: return obj helmutg-asyncvarlink-185e456/asyncvarlink/error.py000066400000000000000000000206551526214135700223710ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Represent a varlink error values in Python.""" import functools import typing from .conversion import ObjectVarlinkType, VarlinkType from .message import VarlinkMethodReply from .types import JSONObject, JSONValue, override class _VarlinkErrorParameterDescriptor: def __init__(self, name: str): # Usually, this would be __set_name__, but as we assign with setattr, # we handle the name propagation in the ctor. self.name = name def __get__(self, obj: "TypedVarlinkErrorReply", owner: type) -> JSONValue: try: # mypy does not know that obj.parameters is a JSONObject. return typing.cast(JSONValue, obj.parameters[self.name]) except KeyError as err: raise AttributeError(f"no attribute named {self.name}") from err def __set__(self, obj: "TypedVarlinkErrorReply", value: JSONValue) -> None: obj.parameters[self.name] = value class VarlinkErrorReply(Exception): """Base class for protocol-level errors communicated via Varlink.""" name: str """The name of the error prefixed with the interface name in reverse-domain notation. This may be a class attribute or an instance attribute. """ def paramstojson(self) -> JSONObject: """Represent the parameters as a JSONObject.""" raise NotImplementedError def toreply(self) -> VarlinkMethodReply: """Represent the error as a VarlinkMethodReply.""" return VarlinkMethodReply(self.paramstojson(), error=self.name) def tojson(self) -> JSONObject: """Represent the entire error as a JSONObject.""" return self.toreply().tojson() class GenericVarlinkErrorReply(VarlinkErrorReply): """An untyped VarlinkErrorReply subclass representing errors for which no TypedVarlinkErrorReply subclass exists. """ def __init__(self, error: str, parameters: JSONObject | None = None): """In general, a varlink error object consists of a fully qualified error name and a parameter object and that's what should be supplied here. """ super().__init__() self.name = error self.parameters = {} if parameters is None else parameters @override def paramstojson(self) -> JSONObject: return self.parameters @override def __str__(self) -> str: if not self.parameters: return self.name return f"{self.name}: {self.parameters!r}" @override def __repr__(self) -> str: if not self.parameters: return f"{self.__class__.__name__}({self.name!r})" return f"{self.__class__.__name__}({self.name!r}, {self.parameters!r})" @typing.dataclass_transform() class TypedVarlinkErrorReply(VarlinkErrorReply): """Class hierarchy for type-driven protocol-level errors communicated via Varlink. The name attribute on this hierarchy is a class attribute and not an instance attribute. It may be specified in one of the following ways. * Set the name class attribute to a fully qualified name. * Pass a fully qualified name as keyword argument name for class inheritance. * Pass a qualified interface as keyword argument interface for class inheritance. In this case the actual name will be constructed from the interface and the name of the derived class. Each subclass must provide a parameter specification via one of the following means. * Annotate the attribute parameters as a typing.TypedDict. * Create an inner class named Parameters whose attribute annotations are used to construct a typing.TypedDict. A suitable __init__ method setting the parameters instance attribute will be created automatically. For each parameter, a matching descriptor attribute will be added. The prefix of these attributes may be customized via the paramprefix keyword argument for class inheritance and defaults to "p_". If no parameters clash with keywords or methods, it is suggested to request an empty paramprefix. """ paramtype: ObjectVarlinkType """A VarlinkType instance representing the type of the parameters. It can be used for converting a Python representation and a JSON representation of the parameters back and forth. It is constructed by the __init_subclass__ hook from the annotations of the parameters attribute or the Parameters class attribute. """ @override def __init_subclass__( cls: type["TypedVarlinkErrorReply"], **kwargs: typing.Any ) -> None: set_name = False try: name = cls.name except AttributeError: if "name" in kwargs: name = kwargs.pop("name") set_name = True elif "interface" in kwargs: name = kwargs.pop("interface") + "." + cls.__name__ set_name = True else: raise RuntimeError( "For instantiating a TypedVarlinkErrorReply subclass you " "must set on of the name class attribute, name class " "keyword argument or interface class keyword argument." ) from None paramprefix = kwargs.pop("paramprefix", "p_") super().__init_subclass__(**kwargs) if set_name: cls.name = name try: paramtype = cls.__annotations__["parameters"] except KeyError: try: paramcls = getattr(cls, "Parameters") except AttributeError: raise RuntimeError( "For instantiating a TypedVarlinkErrorReply subclass you " "must provide a type annotation for the parameters " "attribute or add a type annotated inner class named " "Parameters." ) from None # mypy does not like to construct a TypedDict with non-constant # arguments, but that's exactly what we're up to here. It also # objects to assigning a type hint to a variable assigned elsewhere # as a TypedDict may already be defined as an annotation of the # the parameters attribute. Silence both. paramtype = typing.TypedDict( # type: ignore[misc,no-redef] f"{cls.__name__}Parameters", paramcls.__annotations__ ) cls.__annotations__["parameters"] = paramtype else: if not typing.is_typeddict(paramtype): raise RuntimeError( "The parameters attribute must be annotated as a " "typing.TypedDict." ) vtype = VarlinkType.from_type_annotation(paramtype) # As we pass a TypedDict, we actually get an ObjectVarlinkType. assert isinstance(vtype, ObjectVarlinkType) cls.paramtype = vtype # The type annotation given here does not really matter as we override # it in the next step. @functools.wraps(cls.__init__) def subclass_init( self: typing.Any, _mapping: JSONObject | None = None, /, **kwargs: JSONValue, ) -> None: super().__init__() if _mapping is None: self.parameters = kwargs else: self.parameters = _mapping | kwargs subclass_init.__annotations__ = { "_mapping": paramtype, } | paramtype.__annotations__ setattr(cls, "__init__", subclass_init) for key, ann in paramtype.__annotations__.items(): desc = _VarlinkErrorParameterDescriptor(key) descname = paramprefix + key setattr(cls, descname, desc) cls.__annotations__[descname] = ann @override def paramstojson(self) -> JSONObject: ret = self.paramtype.tojson(self.parameters) assert isinstance(ret, dict) # self.parameters is a dict. return ret if typing.TYPE_CHECKING: # Pretend that we have an __init__ as mypy does not recognize the # generated __init__ method. def __init__( self, _mapping: JSONObject | None = None, /, **_kwargs: JSONValue ): ... # Pretend that we have __getattr__ such that mypy and pylint do not # moan about accessing our descriptor objects. Unfortunately, this # entirely breaks checking attributes. def __getattr__(self, attr: str) -> typing.Any: ... helmutg-asyncvarlink-185e456/asyncvarlink/interface.py000066400000000000000000000553241526214135700232010ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Model a varlink interface method and its type.""" import collections.abc import dataclasses import functools import inspect import typing from .conversion import ( ForeignVarlinkType, ObjectVarlinkType, OptionalVarlinkType, VarlinkType, _merge_typedefs, ) from .error import GenericVarlinkErrorReply, TypedVarlinkErrorReply from .message import VarlinkMethodReply from .types import JSONObject, override, validate_interface _P = typing.ParamSpec("_P") _R = typing.TypeVar("_R") @dataclasses.dataclass class VarlinkMethodSignature: """Annotate methods with such a signature object to indicate that they are meant to be called via varlink. The signature contains prepared information about the parameter conversion and how to call it. """ asynchronous: bool """Indicates whether the method is async or not.""" more: bool """Indicates whether the method returns and iterable or not.""" parameter_type: ObjectVarlinkType """A type for the call parameters.""" return_type: ObjectVarlinkType """A type for the return parameters.""" @dataclasses.dataclass class AnnotatedResult: """A wrapper for unconverted varlink reply values to affect protocol-level details. """ parameters: collections.abc.Mapping[str, typing.Any] """Parameter mapping that would normally returned without this wrapper.""" _: dataclasses.KW_ONLY continues: bool = False """Indicates whether another reply follows this reply. Setting this is only valid if the caller enabled the "more" flag. If setting this to True, another reply must follow. Otherwise, this must be the last reply. """ extensions: JSONObject = dataclasses.field(default_factory=dict) """May be used to supply extension fields on the varlink reply level.""" class LastResult(Exception): """An varlink-specific exception for signalling a non-continued reply after a sequence of continued replies.""" def __init__(self, value: typing.Any): """Return the given value as the last reply.""" if isinstance(value, AnnotatedResult) and value.continues: raise RuntimeError( "An AnnotatedResult raised via LastResult cannot continue." ) self.value = value _MethodResultType = ( AnnotatedResult | collections.abc.Awaitable[AnnotatedResult] | collections.abc.Iterator[AnnotatedResult] | collections.abc.AsyncIterator[AnnotatedResult] ) def _params_to_varlinkobj( params: collections.abc.Iterator[tuple[str, inspect.Parameter]], *, allow_foreign: bool = False, ) -> ObjectVarlinkType: typemap = {} populatenone = set() discardnone = set() for name, tobj in params: vtype = VarlinkType.from_type_annotation(tobj.annotation) if (not allow_foreign) and any( isinstance(vt, ForeignVarlinkType) for vt in vtype.traverse() ): raise TypeError( f"Use of foreign not enabled for parameter {name} typed " f"{tobj.annotation!r}" ) if tobj.default is tobj.empty: typemap[name] = vtype if isinstance(vtype, OptionalVarlinkType): populatenone.add(name) else: typemap[name] = vtype.optional() discardnone.add(name) return ObjectVarlinkType( typemap, populatenone=populatenone, discardnone=discardnone ) class _VarlinkMethodDecorator(typing.Protocol): @typing.overload def __call__( self, function: collections.abc.Callable[ _P, collections.abc.AsyncIterator[_R] ], ) -> collections.abc.Callable[ _P, collections.abc.AsyncIterator[AnnotatedResult] ]: ... @typing.overload def __call__( self, function: collections.abc.Callable[_P, collections.abc.Iterator[_R]], ) -> collections.abc.Callable[ _P, collections.abc.Iterator[AnnotatedResult] ]: ... @typing.overload def __call__( self, function: collections.abc.Callable[_P, collections.abc.Awaitable[_R]], ) -> collections.abc.Callable[ _P, collections.abc.Awaitable[AnnotatedResult] ]: ... @typing.overload def __call__( self, function: collections.abc.Callable[_P, _R] ) -> collections.abc.Callable[_P, _MethodResultType]: ... class _NotGiven: pass _notgiven = _NotGiven() @typing.overload def varlinkmethod( function: collections.abc.Callable[_P, collections.abc.AsyncIterator[_R]], *, return_parameter: str | None = None, return_type: type | _NotGiven = _notgiven, delay_generator: bool = True, allow_foreign: bool = False, ) -> collections.abc.Callable[ _P, collections.abc.AsyncIterator[AnnotatedResult] ]: ... @typing.overload def varlinkmethod( function: collections.abc.Callable[_P, collections.abc.Iterator[_R]], *, return_parameter: str | None = None, return_type: type | _NotGiven = _notgiven, delay_generator: bool = True, allow_foreign: bool = False, ) -> collections.abc.Callable[ _P, collections.abc.Iterator[AnnotatedResult] ]: ... @typing.overload def varlinkmethod( function: collections.abc.Callable[_P, collections.abc.Awaitable[_R]], *, return_parameter: str | None = None, return_type: type | _NotGiven = _notgiven, delay_generator: bool = True, allow_foreign: bool = False, ) -> collections.abc.Callable[ _P, collections.abc.Awaitable[AnnotatedResult] ]: ... @typing.overload def varlinkmethod( function: collections.abc.Callable[_P, _R], *, return_parameter: str | None = None, return_type: type | _NotGiven = _notgiven, delay_generator: bool = True, allow_foreign: bool = False, ) -> collections.abc.Callable[_P, _MethodResultType]: ... @typing.overload def varlinkmethod( *, return_parameter: str | None = None, return_type: type | _NotGiven = _notgiven, delay_generator: bool = True, allow_foreign: bool = False, ) -> _VarlinkMethodDecorator: ... # Whilst the Python documentation says the implementation should be untyped, # mypy insists on having a type to type check the body of the function. # https://github.com/python/mypy/issues/3360 def varlinkmethod( function: collections.abc.Callable[_P, _R] | None = None, *, return_parameter: str | None = None, return_type: type | _NotGiven = _notgiven, delay_generator: bool = True, allow_foreign: bool = False, ) -> ( collections.abc.Callable[ [collections.abc.Callable[_P, _R]], collections.abc.Callable[_P, _MethodResultType], ] | collections.abc.Callable[_P, _MethodResultType] ): """Decorator for fully type annotated methods to be callable from varlink. The function may be a generator, in which case it should be called with the "more" field set on the varlink side. The function has multiple options to return or yield values. It may produce AnnotatedResult instances. If return_parameter is None, it may produce a dict and a bare value to be wrapped in a dict with return_parameter as key otherwise. To enable implementations to return or yield AnnotatedResult objects, they may be annotated as returning AnnotatedResult or generators thereof and pass the type annotation used for introspection via the return_type parameter. Note that the return_type should be the element type for generators. If the function is a generator (async or not), its return type annotation must reflect this via Iterator or AsyncIterator. Yielded AnnotatedResult instances are forwarded immediately. They must correctly indicate whether the generator produces another element by setting the continues field appropriately. If delay_generator is True, other values will be delayed until the next element is produced (and thus the continues field for the previous element is known). Otherwise, all values are forwarded immediately assuming that generator never finishes via StopIteration or AsyncStopIteration. In the latter case, a final value may produced by raising it inside as LastResult exception. The function must produce at least one result or raise a LastResult exception. If the allow_foreign is enabled, unknown Python types are represented as ForeignVarlinkType. Otherwise, the decorator fails when encountering an unknown Python type. """ def wrap( function: collections.abc.Callable[_P, _R], ) -> collections.abc.Callable[_P, _MethodResultType]: asynchronous = inspect.iscoroutinefunction(function) asyncgen = inspect.isasyncgenfunction(function) signature = inspect.signature(function) param_iterator = iter(signature.parameters.items()) if next(param_iterator)[0] != "self": raise RuntimeError( "first argument of a method should be named self" ) return_pytype = signature.return_annotation more = False ret_origin = typing.get_origin(return_pytype) if ret_origin is not None and issubclass( ret_origin, ( collections.abc.AsyncIterator if asyncgen else collections.abc.Iterator ), ): return_pytype = typing.get_args(return_pytype)[0] more = True return_vtype: VarlinkType make_result: collections.abc.Callable[[_R], AnnotatedResult] if return_type is not _notgiven: return_pytype = return_type if return_pytype is None: return_vtype = ObjectVarlinkType({}) def make_result(_result: _R) -> AnnotatedResult: return AnnotatedResult({}) else: return_vtype = VarlinkType.from_type_annotation(return_pytype) if (not allow_foreign) and any( isinstance(vt, ForeignVarlinkType) for vt in return_vtype.traverse() ): raise TypeError( f"Use of foreign not enabled for return type {return_pytype!r}" ) if return_parameter is not None: return_vtype = ObjectVarlinkType( {return_parameter: return_vtype} ) def make_result(result: _R) -> AnnotatedResult: return AnnotatedResult({return_parameter: result}) elif not isinstance(return_vtype, ObjectVarlinkType): raise TypeError("a varlinkmethod must return a mapping") else: # mypy cannot figure out that a type also is a Callable. make_result = typing.cast( collections.abc.Callable[[_R], AnnotatedResult], AnnotatedResult, ) vlsig = VarlinkMethodSignature( asyncgen or asynchronous, more, _params_to_varlinkobj(param_iterator, allow_foreign=allow_foreign), return_vtype, ) wrapped: collections.abc.Callable[_P, typing.Any] if more and asyncgen: asynciterfunction = typing.cast( collections.abc.Callable[ _P, collections.abc.AsyncIterable[_R] ], function, ) if delay_generator: @functools.wraps(function) async def wrapped( *args: _P.args, **kwargs: _P.kwargs ) -> collections.abc.AsyncGenerator[AnnotatedResult, None]: pending = None try: async for result in asynciterfunction(*args, **kwargs): if pending is not None: pending.continues = True yield pending pending = None if isinstance(result, AnnotatedResult): yield result else: pending = make_result(result) except LastResult as exc: if pending is not None: pending.continues = True yield pending if isinstance(exc.value, AnnotatedResult): yield exc.value else: yield make_result(exc.value) except: if pending is not None: pending.continues = True yield pending raise else: if pending is not None: yield pending else: @functools.wraps(function) async def wrapped( *args: _P.args, **kwargs: _P.kwargs ) -> collections.abc.AsyncGenerator[AnnotatedResult, None]: try: async for result in asynciterfunction(*args, **kwargs): if isinstance(result, AnnotatedResult): yield result else: ares = make_result(result) ares.continues = True yield ares except LastResult as exc: if isinstance(exc.value, AnnotatedResult): yield exc.value else: yield make_result(exc.value) elif more: iterfunction = typing.cast( collections.abc.Callable[_P, collections.abc.Iterable[_R]], function, ) if delay_generator: @functools.wraps(function) def wrapped( *args: _P.args, **kwargs: _P.kwargs ) -> collections.abc.Generator[AnnotatedResult, None, None]: pending = None try: for result in iterfunction(*args, **kwargs): if pending is not None: pending.continues = True yield pending pending = None if isinstance(result, AnnotatedResult): yield result else: pending = make_result(result) except LastResult as exc: if pending is not None: pending.continues = True yield pending if isinstance(exc.value, AnnotatedResult): yield exc.value else: yield make_result(exc.value) except: if pending is not None: pending.continues = True yield pending raise else: if pending is not None: yield pending else: @functools.wraps(function) def wrapped( *args: _P.args, **kwargs: _P.kwargs ) -> collections.abc.Generator[AnnotatedResult, None, None]: try: for result in iterfunction(*args, **kwargs): if isinstance(result, AnnotatedResult): yield result else: ares = make_result(result) ares.continues = True yield ares except LastResult as exc: if isinstance(exc.value, AnnotatedResult): yield exc.value else: yield make_result(exc.value) elif asynchronous: asyncfunction = typing.cast( collections.abc.Callable[_P, collections.abc.Awaitable[_R]], function, ) @functools.wraps(function) async def wrapped( *args: _P.args, **kwargs: _P.kwargs ) -> AnnotatedResult: try: result = await asyncfunction(*args, **kwargs) except LastResult as exc: result = exc.value if isinstance(result, AnnotatedResult): return result return make_result(result) else: @functools.wraps(function) def wrapped( *args: _P.args, **kwargs: _P.kwargs ) -> AnnotatedResult: try: result = function(*args, **kwargs) except LastResult as exc: result = exc.value if isinstance(result, AnnotatedResult): return result return make_result(result) # Employ setattr instead of directly setting it as that makes mypy and # pylint a lot happier. setattr(wrapped, "_varlink_signature", vlsig) return wrapped if function is None: # The decorator is called with parens. return wrap return wrap(function) def varlinksignature( method: collections.abc.Callable[_P, _R], ) -> VarlinkMethodSignature | None: """Return the signature object constructed by the varlinkmethod decorator if the given method has been decorated. """ # Wrap the access in getattr as that makes mypy and pylint a lot happier. return getattr(method, "_varlink_signature", None) def _doc_to_comment(doc: str) -> typing.Iterator[str]: """Turn a doc string into a varlink IDL compatible comment.""" return map("# ".__add__, inspect.cleandoc(doc).rstrip("\r\n").splitlines()) class VarlinkInterface: """A base class for varlink interface implementations. Deriving classes should set the name class attribute to the interface name and mark the interface methods with the varlinkmethod decorator. """ name: str """The name of the varlink interface in dotted reverse domain notation.""" errors: collections.abc.Sequence[type[TypedVarlinkErrorReply]] = () """Optionally, an interface may list the possible errors that may be returned. For use with a server, this causes the errors with matching interface to be included in the rendered interface description. For use with a client proxy, this causes the error classes to be used for decoding error replies. """ _error_map: dict[str, type[TypedVarlinkErrorReply]] """Constructed by __init_subclass__ from the errors sequence.""" @override def __init_subclass__( cls: type["VarlinkInterface"], *, name: str | None = None ) -> None: try: cls.name except AttributeError: if name is None: raise RuntimeError( "VarlinkInterface subclasses must define an interface name" ) from None cls.name = name else: if name is not None: raise RuntimeError( "cannot specify VarlinkInterface name both via " "inheritance and attribute" ) try: validate_interface(cls.name) except ValueError as err: raise RuntimeError("invalid VarlinkInterface name") from err error_map = {} for error in cls.errors: if error.name in error_map: raise RuntimeError(f"duplicate error {error.name}") error_map[error.name] = error cls._error_map = error_map @classmethod def _generate_interface_description( cls, comments: bool ) -> typing.Iterator[str]: if comments and cls.__doc__: yield from _doc_to_comment(cls.__doc__) yield f"interface {cls.name}" typedefs: dict[str, str] = {} methods: list[str] = [] for name in dir(cls): obj = getattr(cls, name) if (signature := varlinksignature(obj)) is None: continue _merge_typedefs( typedefs, signature.parameter_type.typedefs, signature.return_type.typedefs, ) if comments and obj.__doc__: methods.append("") methods.extend(_doc_to_comment(obj.__doc__)) methods.append( f"method {name}{signature.parameter_type.as_varlink} -> " f"{signature.return_type.as_varlink}" ) errors: list[str] = [] prefix = cls.name + "." for error in cls.errors: name = error.name.removeprefix(prefix) if name != error.name: _merge_typedefs(typedefs, error.paramtype.typedefs) if comments and error.__doc__: errors.append("") errors.extend(_doc_to_comment(error.__doc__)) errors.append(f"error {name} {error.paramtype.as_varlink}") if typedefs or not comments: yield "" for tname, tdef in typedefs.items(): yield f"type {tname} {tdef}" if typedefs and methods and methods[0]: yield "" yield from methods if methods and errors and errors[0]: yield "" yield from errors if methods or errors: yield "" @classmethod def render_interface_description(cls, *, comments: bool = True) -> str: """Render a varlink interface description from this interface. Refer to https://varlink.org/Interface-Definition. The comments parameter indicates whether __doc__ strings should be included as comments. """ return "\n".join(cls._generate_interface_description(comments)) @classmethod def raise_error(cls, reply: VarlinkMethodReply) -> typing.NoReturn: """Raise an exception from the given reply. It must be an error reply. If the error name is found in the errors sequence a TypedVarlinkErrorReply subclass is constructed. In that case, the given parameters are validated and may raise a ConversionError. """ if reply.error is None: raise RuntimeError("raise_error invoked on non-error reply") try: errcls = cls._error_map[reply.error] except KeyError: raise GenericVarlinkErrorReply( reply.error, reply.parameters ) from None raise errcls(errcls.paramtype.fromjson(reply.parameters)) helmutg-asyncvarlink-185e456/asyncvarlink/message.py000066400000000000000000000143721526214135700226630ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Representation of varlink method calls and replies as Python objects.""" import dataclasses from .types import JSONObject, JSONValue, validate_interface, validate_name @dataclasses.dataclass class VarlinkMethodCall: """Represent a parsed and roughly validated varlink method call.""" method: str parameters: JSONObject oneway: bool = False more: bool = False upgrade: bool = False extensions: JSONObject = dataclasses.field(default_factory=dict) @property def method_interface(self) -> str: """Return the interface portion of the method string. May raise a ValueError. """ interface, dot, _ = self.method.rpartition(".") if dot != ".": raise ValueError("unqualified method string") return interface @property def method_name(self) -> str: """Return the unqualified name portion of the method string.""" return self.method.rpartition(".")[2] def __post_init__(self) -> None: validate_interface(self.method_interface) validate_name(self.method_name) @classmethod def fromjson(cls, obj: JSONValue) -> "VarlinkMethodCall": """Parse a JSON value into a validated VarlinkMethodCall. May raise TypeError and ValueError. """ if not isinstance(obj, dict): raise TypeError( f"call object must be a map, is {obj.__class__.__name__}" ) extensions = obj.copy() try: method = extensions.pop("method") except KeyError: raise ValueError("call object must have a method") from None if not isinstance(method, str): raise TypeError( f"method field of call object must be a str, is " f"{method.__class__.__name__}" ) parameters = extensions.pop("parameters", {}) if not isinstance(parameters, dict): raise TypeError( f"call parameters must be map, are " f"{parameters.__class__.__name__}" ) oneway = extensions.pop("oneway", False) if not isinstance(oneway, bool): raise TypeError( f"call property oneway must be bool, is " f"{oneway.__class__.__name__}" ) more = extensions.pop("more", False) if not isinstance(more, bool): raise TypeError( f"call property more must be bool, is " f"{more.__class__.__name__}" ) upgrade = extensions.pop("upgrade", False) if not isinstance(upgrade, bool): raise TypeError( f"call property upgrade must be bool, is " f"{upgrade.__class__.__name__}" ) if sum((oneway, more, upgrade)) > 1: raise ValueError("cannot combine oneway, more or upgrade") return cls( method, parameters, oneway, more, upgrade, extensions, ) def tojson(self) -> JSONObject: """Export as a JSONObject suitable for json.dumps.""" result: JSONObject = {"method": self.method} if self.parameters: result["parameters"] = self.parameters if self.oneway: result["oneway"] = True if self.more: result["more"] = True if self.upgrade: result["upgrade"] = True result.update(self.extensions) return result @dataclasses.dataclass class VarlinkMethodReply: """Represent a parsed and roughly validated varlink method reply.""" parameters: JSONObject continues: bool = False error: str | None = None extensions: JSONObject = dataclasses.field(default_factory=dict) @property def error_interface(self) -> str: """Return the interface portion of the error string if any. May raise a ValueError. """ if self.error is None: raise ValueError("not an error") interface, dot, _ = self.error.rpartition(".") if dot != ".": raise ValueError("unqualified error string") return interface @property def error_name(self) -> str: """Return the unqualified name portion of the error string if any. May raise a ValueError. """ if self.error is None: raise ValueError("not an error") return self.error.rpartition(".")[2] def __post_init__(self) -> None: if self.error is not None: validate_interface(self.error_interface) validate_name(self.error_name) @classmethod def fromjson(cls, obj: JSONValue) -> "VarlinkMethodReply": """Parse a JSON value into a validated VarlinkMethodReply. May raise TypeError and ValueError. """ if not isinstance(obj, dict): raise TypeError( f"call object must be a map, is {obj.__class__.__name__}" ) extensions = obj.copy() parameters = extensions.pop("parameters", {}) if not isinstance(parameters, dict): raise TypeError( f"reply parameters must be map, are " f"{parameters.__class__.__name__}" ) continues = extensions.pop("continues", False) if not isinstance(continues, bool): raise TypeError( f"reply property continues must be bool, is " f"{continues.__class__.__name__}" ) error = extensions.pop("error", None) if error is not None and not isinstance(error, str): raise TypeError( f"reply property error must be str, is " f"{error.__class__.__name__}" ) return VarlinkMethodReply(parameters, continues, error, extensions) def tojson(self) -> JSONObject: """Export as a JSONObject suitable for json.dumps.""" result: JSONObject = {} if self.continues: result["continues"] = True if self.error: result["error"] = self.error if self.parameters: result["parameters"] = self.parameters result.update(self.extensions) return result helmutg-asyncvarlink-185e456/asyncvarlink/protocol.py000066400000000000000000000554211526214135700231000ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """asyncio and protocol level varlink functionality.""" import asyncio import collections import collections.abc import errno import functools import json import logging import os import socket import typing import warnings from .types import ( FileDescriptor, FileDescriptorArray, HasFileno, HasFilenoAndClose, JSONObject, override, ) _logger = logging.getLogger("asyncvarlink.protocol") _SENTINEL = object() class VarlinkBaseProtocol(asyncio.BaseProtocol): """An asyncio protocol that provides the interface expected by VarlinkTransport, but does not actually implement any of the wire protocol. The typical data_received used by other streaming protocols is replaced with message_received. """ def message_received( self, data: bytes, fds: FileDescriptorArray | None ) -> None: """Called when the transport received new data. The data can be accompanied by open file descriptors. The caller will release the fds array and all contained file descriptors that have not been taken (FileDescriptorArray.take) once message_received returns None. Life time of fds can be extended by adding a reference prior to returning. """ def eof_received(self) -> None: """Callback for signalling the end of messages on the receiving side. The default implementation does nothing. Same as Protocol.eof_received. """ def _check_socket(thing: socket.socket | int | HasFileno) -> HasFilenoAndClose: """Attempt to upgrade a file descriptor into a socket object if it happens to be a socket. """ if isinstance(thing, socket.socket): return thing if not hasattr(thing, "fileno"): if not isinstance(thing, int): raise TypeError("not a file descriptor") thing = FileDescriptor(thing) elif not hasattr(thing, "close"): thing = FileDescriptor(thing) elif typing.TYPE_CHECKING: # In principle, it could be an incompatible close method, so mypy # rejects this otherwise. thing = typing.cast(HasFilenoAndClose, thing) try: sock = socket.socket(fileno=thing.fileno()) except OSError as err: if err.errno == errno.ENOTSOCK: return thing raise if sock.type != socket.SOCK_STREAM: raise ValueError("the given socket is not SOCK_STREAM") return sock def varlink_boundaries(data: bytes) -> list[int]: """Return the lengths of message chunks for the varlink protocol in the supplied data. """ parts = data.split(b"\0") if parts: # If data ends with a nul byte, we are popping an empty string. # Otherwise, the incomplete chunk is popped. parts.pop() return [len(part) + 1 for part in parts] _BLOCKING_ERRNOS = frozenset((errno.EWOULDBLOCK, errno.EAGAIN)) # pylint: disable=too-many-instance-attributes # Yes, we need them all class VarlinkTransport(asyncio.BaseTransport): """A specialized asyncio Transport class for use with varlink and file descriptor passing. As such, it provides send_message rather than write and expects the protocol class to provide message_received rather than read. It also allows sending and receiving to happen on different file descriptors to facilitate use with stdin and stdout pipes. """ MAX_RECV_FDS = 1024 """The maximum number of file descriptors that will be expected in a single varlink message. """ RECV_BUFFER_SIZE = 4096 """The buffer size used for receiving message data.""" closed_future: asyncio.Future[None] """A future that becomes ready once the transport is closed.""" # pylint: disable=too-many-arguments # Yes, we need five arguments. def __init__( self, loop: asyncio.AbstractEventLoop, recvfd: socket.socket | int | HasFileno, sendfd: socket.socket | int | HasFileno, protocol: VarlinkBaseProtocol, extra: collections.abc.Mapping[str, typing.Any] | None = None, *, boundary_predictor: ( collections.abc.Callable[[bytes], list[int]] | None ) = varlink_boundaries, ): super().__init__(extra) self._loop = loop self._recvfd: HasFilenoAndClose | None = _check_socket(recvfd) self._boundary_predictor = boundary_predictor self._peeked_boundaries: list[int] = [] if isinstance(self._recvfd, socket.socket): if boundary_predictor is None: self._handle_read = self._handle_read_socket else: self._handle_read = self._handle_read_socket_peek else: self._handle_read = self._handle_read_fd self._paused = True os.set_blocking(self._recvfd.fileno(), False) self._sendfd: HasFilenoAndClose | None if recvfd is sendfd: self._sendfd = self._recvfd else: self._sendfd = _check_socket(sendfd) os.set_blocking(self._sendfd.fileno(), False) if isinstance(self._sendfd, socket.socket): self._do_write = self._do_write_socket else: self._do_write = self._do_write_fd # Using a deque as we only do end operations and those become O(1). self._sendqueue: collections.deque[ tuple[ list[bytes], # data to be sent list[int], # file descriptors to be sent asyncio.Future[None], # completion notification ] ] self._sendqueue = collections.deque() self._closing = False self.set_protocol(protocol) self._loop.call_soon(self._protocol.connection_made, self) self._loop.call_soon(self._resume_reading) self.closed_future = self._loop.create_future() @override def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: assert isinstance(protocol, VarlinkBaseProtocol) self._protocol = protocol @override def get_protocol(self) -> VarlinkBaseProtocol: return self._protocol def _close_receiver(self, exc: Exception | None = None) -> None: """Close the receiving side. Without an exception, the connection is lost only once both sides are closed, so the peer may half-close it. An exception means the peer is gone, so the connection is lost now and the protocol is told why. """ if self._recvfd is None: return loose_connection = False if self._sendfd is None or exc is not None: loose_connection = not self._closing self._closing = True if not self._paused: self._loop.remove_reader(self._recvfd) if ( self._sendfd is None or self._recvfd.fileno() != self._sendfd.fileno() ): self._recvfd.close() self._recvfd = None if loose_connection: self._loop.call_soon(self._connection_lost, exc) def _sock_receive_fds(self, size: int) -> int: assert isinstance(self._recvfd, socket.socket) try: msg, fds, _flags, _addr = socket.recv_fds( self._recvfd, size, self.MAX_RECV_FDS ) except OSError as err: if err.errno in _BLOCKING_ERRNOS: return 0 _logger.debug( "%r: reading from socket failed", self, exc_info=True ) self._close_receiver(err) return 0 if msg: ownedfds = FileDescriptorArray(_SENTINEL, fds) if fds else None try: self._protocol.message_received(msg, ownedfds) finally: if ownedfds: ownedfds.release(_SENTINEL) else: for fd in fds: os.close(fd) self._loop.remove_reader(self._recvfd) self._loop.call_soon(self._eof_received) return len(msg) def _handle_read_socket(self) -> None: self._sock_receive_fds(self.MAX_RECV_FDS) def _handle_read_socket_peek(self) -> None: assert isinstance(self._recvfd, socket.socket) assert self._boundary_predictor is not None while True: if self._peeked_boundaries: received = self._sock_receive_fds(self._peeked_boundaries[0]) if received: self._peeked_boundaries[0] -= received if self._peeked_boundaries[0] <= 0: del self._peeked_boundaries[0] else: break else: # In order to correctly associate fds with data, we must first # figure out message boundaries and then receive appropriately # sized chunks. try: data = self._recvfd.recv( self.RECV_BUFFER_SIZE, socket.MSG_PEEK ) except OSError as err: if err.errno in _BLOCKING_ERRNOS: return _logger.debug( "%r: peeking at socket failed", self, exc_info=True ) self._close_receiver(err) return self._peeked_boundaries = self._boundary_predictor(data) if not self._peeked_boundaries: self._peeked_boundaries.append(self.RECV_BUFFER_SIZE) def _handle_read_fd(self) -> None: assert self._recvfd is not None try: data = os.read(self._recvfd.fileno(), self.RECV_BUFFER_SIZE) except OSError as err: if err.errno in _BLOCKING_ERRNOS: return _logger.debug( "%r: reading from socket failed", self, exc_info=True ) self._close_receiver(err) return if data: self._protocol.message_received(data, None) else: self._loop.remove_reader(self._recvfd) self._loop.call_soon(self._eof_received) def pause_reading(self) -> None: """Pause receiving messages. No data will be passed to the protocol's message_received() method until resume_reading is called. """ if self._closing or self._recvfd is None or self._paused: return self._paused = True self._loop.remove_reader(self._recvfd) def pause_receiving(self) -> None: """Deprecated alias of pause_reading.""" warnings.warn( "method renamed to pause_reading", DeprecationWarning, stacklevel=2, ) self.pause_reading() def _resume_reading(self) -> None: if self._closing or self._recvfd is None or not self._paused: return self._paused = False self._loop.add_reader(self._recvfd, self._handle_read) def resume_reading(self) -> None: """Resume receiving messages. Received messages will be passed to the protocol's message_received method again. """ if self._closing or self._recvfd is None or not self._paused: return self._loop.call_soon(self._resume_reading) def resume_receiving(self) -> None: """Deprecated alias of resume_reading.""" warnings.warn( "method renamed to resume_reading", DeprecationWarning, stacklevel=2, ) self.resume_reading() def send_message( self, data: bytes, fds: list[int] | None = None ) -> asyncio.Future[None]: """Enqueue the given data and file descriptors for sending. In case file descriptors are provided, they will be delivered combined using sendmsg. Otherwise, messages may be concatenated. The returned future will be done when the message has been sent. The given file descriptors should remain open until then and the responsibility for closing them remains with the caller. """ if self._do_write is self._do_write_fd and fds: raise ValueError("cannot send fds on non-socket transport") if fds is None: fds = [] if self._closing or self._sendfd is None: _logger.warning("%r: attempt to write to closed transport", self) fut = self._loop.create_future() fut.set_exception(BrokenPipeError()) return fut if self._sendqueue: lastitem = self._sendqueue[-1] if lastitem[1] or fds: fut = self._loop.create_future() self._sendqueue.append(([data], fds, fut)) else: fut = lastitem[2] lastitem[0].append(data) else: fut = self._loop.create_future() self._sendqueue.append(([data], fds, fut)) self._loop.call_soon( self._loop.add_writer, self._sendfd, self._handle_write ) return fut def _fail_sendqueue(self) -> None: while self._sendqueue: _, _, fut = self._sendqueue.popleft() fut.set_exception(BrokenPipeError()) def _close_sender(self) -> None: if self._sendfd is None: return self._loop.remove_writer(self._sendfd) loose_connection = False if self._recvfd is None: loose_connection = not self._closing self._closing = True if ( self._recvfd is None or self._recvfd.fileno() != self._sendfd.fileno() ): self._sendfd.close() self._sendfd = None if loose_connection: self._loop.call_soon(self._connection_lost) def _handle_write(self) -> None: assert self._sendfd is not None while self._sendqueue: data, fds, fut = self._sendqueue.popleft() try: sent = self._do_write(data, fds) except OSError as err: if err.errno in _BLOCKING_ERRNOS: self._sendqueue.appendleft((data, fds, fut)) else: _logger.debug("%r: sending failed", self, exc_info=True) self._close_sender() fut.set_exception(err) self._fail_sendqueue() return while sent > 0: assert data if sent >= len(data[0]): sent -= len(data.pop(0)) else: data[0] = data[0][sent:] sent = 0 if data: self._sendqueue.appendleft((data, [], fut)) else: fut.set_result(None) if not self._sendqueue: if self._closing: self._close_sender() else: self._loop.remove_writer(self._sendfd) def _do_write_socket(self, data: list[bytes], fds: list[int]) -> int: assert isinstance(self._sendfd, socket.socket) if fds: return socket.send_fds(self._sendfd, data, fds) return self._sendfd.sendmsg(data) def _do_write_fd(self, data: list[bytes], fds: list[int]) -> int: assert not fds assert self._sendfd is not None return os.writev(self._sendfd.fileno(), data) def _eof_received(self) -> None: try: self._protocol.eof_received() finally: self._close_receiver() def _connection_lost(self, exc: Exception | None = None) -> None: assert self._closing try: self._protocol.connection_lost(exc) finally: self._close_receiver() if not self._sendqueue: self._close_sender() self.closed_future.set_result(None) @override def close(self) -> None: if not self._closing: self._closing = True self._loop.call_soon(self._connection_lost) @override def is_closing(self) -> bool: return self._closing def abort(self) -> None: """Close the transport immediately. In addition to closing, queued transfers will be cancelled. """ self.close() self._fail_sendqueue() # Will soon call self._connection_lost, which immediately closes both # fds as the _sendqueue is now empty. _JSONEncoder = json.JSONEncoder(separators=(",", ":")) class VarlinkProtocol(VarlinkBaseProtocol): """An asyncio protocol that provides message_received() rather than data_received() to accommodate passed file descriptors. """ def __init__(self) -> None: self._recv_buffer = b"" self._consumer_queue: collections.deque[ tuple[ # A closure for invoking the next consumer collections.abc.Callable[[], asyncio.Future[None] | None], # An optional future that should be notified when the consumer # is done consuming (i.e. the future it returned is completed # or it returned None or raised an exception). asyncio.Future[None] | None, ] ] = collections.deque() self._transport: VarlinkTransport | None = None @override def connection_made(self, transport: asyncio.BaseTransport) -> None: assert isinstance(transport, VarlinkTransport) self._transport = transport @override def message_received( self, data: bytes, fds: FileDescriptorArray | None ) -> None: """Handle incoming data by parsing the low-level part of the varlink protocol. Pass on received data via request_received. """ parts = data.split(b"\0") if self._recv_buffer: parts[0] = self._recv_buffer + parts[0] self._recv_buffer = parts.pop() loop = asyncio.get_running_loop() processing = bool(self._consumer_queue) for reqdata in parts: fut = None if fds: fut = loop.create_future() fds.reference_until_done(fut) call: collections.abc.Callable[[], asyncio.Future[None] | None] try: obj = json.loads(reqdata) except json.decoder.JSONDecodeError as err: call = functools.partial( self.error_received, err, reqdata, fds ) else: if isinstance(obj, dict): call = functools.partial(self.request_received, obj, fds) else: call = functools.partial( self.error_received, TypeError("received JSON value is not an object"), reqdata, fds, ) self._consumer_queue.append((call, fut)) if not processing: loop.call_soon(self._process_queue, None) processing = True fds = None def _process_queue(self, fut: asyncio.Future[None] | None) -> None: if fut is not None: assert fut.done() exc = fut.exception() if exc is not None: _logger.error( "unhandled exception in future from request_received", exc_info=exc, ) fut = None if not self._consumer_queue: if self._transport is not None: self._transport.resume_reading() return consume, notify = self._consumer_queue.popleft() try: fut = consume() except Exception as exception: _logger.error( "unhandled exception from request_received or error_received", exc_info=exception, ) raise finally: if notify is not None: notify.set_result(None) if fut is not None and fut.done(): exc = fut.exception() if exc is not None: _logger.error( "unhandled exception in future from request_received", exc_info=exc, ) fut = None if fut is None: # If the consumer finishes immediately, skip back pressure # via pause_reading as that typically incurs two syscalls. if self._consumer_queue: asyncio.get_running_loop().call_soon( self._process_queue, None ) elif self._transport is not None: self._transport.resume_reading() else: fut.add_done_callback(self._process_queue) if self._transport is not None: self._transport.pause_reading() def request_received( self, obj: JSONObject, fds: FileDescriptorArray | None ) -> asyncio.Future[None] | None: """Handle an incoming varlink request or response object together with associated file descriptors. If the handler returns a future, further processing will be delayed until the future is done. Once the function returns None, the fds will be released unless its life time has been extended by adding another referee. """ raise NotImplementedError # pylint: disable=unused-argument # Arguments provided for inheritance def error_received( self, err: Exception, data: bytes, fds: FileDescriptorArray | None ) -> None: """Handle an incoming protocol violation such as wrongly encoded JSON. The default handler does nothing. """ def send_message( self, obj: JSONObject, fds: list[int] | None = None, autoclose: bool = True, ) -> asyncio.Future[None]: """Send a varlink request or response together with associated file descriptors. The returned future is done once the message actually has been sent or terminally failed sending. In the latter case, the future raises an exception. If autoclose is True, the file descriptors are closed after transmission. Otherwise, the caller is responsible for closing them after completion of the returned future. """ if self._transport is None: fut = asyncio.get_running_loop().create_future() fut.set_exception(BrokenPipeError()) return fut fut = self._transport.send_message( _JSONEncoder.encode(obj).encode("utf8") + b"\0", fds ) if fds and autoclose: @fut.add_done_callback def close_fds(_: asyncio.Future[None]) -> None: for fd in fds: os.close(fd) return fut def close(self) -> None: """Close the connected transport if any.""" if self._transport is not None: try: self._transport.close() finally: self._transport = None helmutg-asyncvarlink-185e456/asyncvarlink/py.typed000066400000000000000000000000001526214135700223430ustar00rootroot00000000000000helmutg-asyncvarlink-185e456/asyncvarlink/serverprotocol.py000066400000000000000000000363001526214135700243220ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """asyncio varlink server protocol implementation""" import asyncio import collections.abc import functools import logging import typing from .conversion import ConversionError, FileDescriptorVarlinkType from .error import VarlinkErrorReply, GenericVarlinkErrorReply from .interface import ( AnnotatedResult, VarlinkInterface, VarlinkMethodSignature, varlinksignature, ) from .message import VarlinkMethodCall, VarlinkMethodReply from .protocol import VarlinkBaseProtocol, VarlinkProtocol from .serviceerrors import ( ExpectedMore, InterfaceNotFound, InvalidParameter, MethodNotFound, ) from .types import FileDescriptorArray, JSONObject, override _logger = logging.getLogger("asyncvarlink.serverprotocol") class VarlinkInterfaceRegistry: """Collection of VarlinkInterface instances.""" def __init__(self) -> None: self.interfaces: dict[str, VarlinkInterface] = {} def register_interface(self, interface: VarlinkInterface) -> None: """Register an interface instance. Its name must be unique to the registry. """ if interface.name in self.interfaces: raise ValueError( f"an interface named {interface.name} is already registered" ) self.interfaces[interface.name] = interface def lookup_method( self, call: VarlinkMethodCall ) -> tuple[ collections.abc.Callable[..., typing.Any], VarlinkMethodSignature ]: """Look up a method. Return the Python callable responsible for the method referenced by the call and its VarlinkMethodSignature used for introspection and type conversion. This raises a number of subclasses of VarlinkErrorReply. """ try: interface = self.interfaces[call.method_interface] except KeyError: raise InterfaceNotFound(interface=call.method_interface) from None try: method = getattr(interface, call.method_name) except AttributeError: raise MethodNotFound(method=call.method_name) from None if (signature := varlinksignature(method)) is None: # Reject any method that has not been marked with varlinkmethod. raise MethodNotFound(method=call.method_name) if signature.more and not call.more: raise ExpectedMore() return (method, signature) def __iter__(self) -> collections.abc.Iterator[VarlinkInterface]: """Iterate over the registered VarlinkInterface instances.""" return iter(self.interfaces.values()) def __getitem__(self, interface: str) -> VarlinkInterface: """Look up a VarlinkInterface by its name. Raises KeyError.""" return self.interfaces[interface] def protocol_factory(self) -> VarlinkBaseProtocol: """Factory method for generating protocol instances. Example: create_unix_server(registry.protocol_factory, ...) """ return VarlinkInterfaceServerProtocol(self) class VarlinkServerProtocol(VarlinkProtocol): """Protocol class for a varlink service. It receives calls as VarlinkMethodCall objects and issues replies as VarlinkMethodReply or VarlinkErrorReply objects. A derived class should implement call_received. """ def __init__(self) -> None: super().__init__() self._inflight: set[asyncio.Future[None]] = set() self._closing = False def _maybe_close_if_idle(self) -> None: if self._closing: while self._inflight: fut = next(iter(self._inflight)) if not fut.done(): return self._inflight.remove(fut) self.close() self._closing = False def _on_transfer_done(self, fut: asyncio.Future[None]) -> None: assert fut.done() self._inflight.discard(fut) self._maybe_close_if_idle() def _add_transfer(self, fut: asyncio.Future[None]) -> None: self._inflight.add(fut) fut.add_done_callback(self._on_transfer_done) def _on_message_sent(self, fut: asyncio.Future[None]) -> None: assert fut.done() exc = fut.exception() if exc is None or isinstance(exc, BrokenPipeError): # The lower layer will close the connection on BrokenPipeError. return _logger.exception("Sending reply failed", exc_info=exc) def send_reply( self, reply: VarlinkMethodReply, fds: list[int] | None = None, autoclose: bool = True, ) -> asyncio.Future[None]: """Enqueue the given reply and file descriptors for sending. For the semantics regarding fds, please refer to the documentation of send_message. """ fut = self.send_message(reply.tojson(), fds, autoclose) fut.add_done_callback(self._on_message_sent) self._add_transfer(fut) return fut def send_error(self, error: VarlinkErrorReply) -> asyncio.Future[None]: """Enqueue the given error for sending. The future shall be done when the error has been sent. """ try: reply = error.toreply() except ConversionError as err: reply = InvalidParameter(parameter=err.location[0]).toreply() return self.send_reply(reply) def _on_receiver_completes( self, backpressure_fut: asyncio.Future[None], oneway: bool, call_fut: asyncio.Future[None], ) -> None: assert call_fut.done() exc = call_fut.exception() if exc is not None: if isinstance(exc, VarlinkErrorReply): if not oneway: self.send_error(exc) elif isinstance(exc, (SystemExit, KeyboardInterrupt)): raise exc else: try: self.handle_call_exception(exc, oneway) except VarlinkErrorReply as err: if not oneway: self.send_error(err) backpressure_fut.set_result(None) def handle_call_exception(self, exc: BaseException, oneway: bool) -> None: """React to call_received emitting an exception not inheriting from VarlinkErrorReply. The caught exception is provided. The default implementation logs the exception and sends an error reply unless oneway is True. The function may raise a VarlinkErrorReply and have it sent when needed. """ _logger.exception( "unhandled exception from call_received future", exc_info=exc ) if not oneway: self.send_error( GenericVarlinkErrorReply( "invalid.asyncvarlink.InternalServerError" ) ) @override def request_received( self, obj: JSONObject, fds: FileDescriptorArray | None ) -> asyncio.Future[None] | None: try: try: call = VarlinkMethodCall.fromjson(obj) except (TypeError, ValueError): raise GenericVarlinkErrorReply( "invalid.asyncvarlink.ProtocolViolation" ) from None fut = self.call_received(call, fds) if fut is None: return None bpfut = asyncio.get_running_loop().create_future() fut.add_done_callback( functools.partial( self._on_receiver_completes, bpfut, call.oneway ), ) self._add_transfer(fut) return bpfut except VarlinkErrorReply as err: if not obj.get("oneway", False): self.send_error(err) return None except (SystemExit, KeyboardInterrupt): raise except BaseException as exc: oneway = bool(obj.get("oneway", False)) try: self.handle_call_exception(exc, oneway) except VarlinkErrorReply as err: if not oneway: self.send_error(err) return None @override def eof_received(self) -> None: super().eof_received() self._closing = True self._maybe_close_if_idle() @override def connection_lost(self, exc: Exception | None) -> None: super().connection_lost(exc) self.close() def call_received( self, call: VarlinkMethodCall, fds: FileDescriptorArray | None ) -> asyncio.Future[None] | None: """Handle a received varlink parsed as a call object and associated file descriptors. The descriptors are valid until the function returns. Their life time can be extended by adding a referee before returning. If the function raises an exception or the returned future (if any) completes with an exception a reply will be generated. Otherwise, the function is expected to call send_reply as needed before returning None or completing the returned future. """ raise NotImplementedError class VarlinkInterfaceServerProtocol(VarlinkServerProtocol): """Serve the interfaces registered with a registry via varlink.""" def __init__(self, registry: VarlinkInterfaceRegistry) -> None: """Defer method lookup to the given registry.""" super().__init__() self._registry = registry @override def call_received( self, call: VarlinkMethodCall, fds: FileDescriptorArray | None ) -> asyncio.Future[None] | None: method, signature = self._registry.lookup_method(call) try: pyparams = signature.parameter_type.fromjson( call.parameters, {FileDescriptorVarlinkType: fds} ) except ConversionError as err: raise InvalidParameter(parameter=err.location[0]) from err if not signature.asynchronous: if signature.more: fut = asyncio.ensure_future( self._call_sync_method_more(method, signature, pyparams) ) else: self._call_sync_method_single( method, signature, pyparams, call.oneway ) return None elif signature.more: fut = asyncio.ensure_future( self._call_async_method_more(method, signature, pyparams) ) else: fut = asyncio.ensure_future( self._call_async_method_single( method, signature, pyparams, call.oneway ), ) if fds is not None: fds.reference_until_done(fut) return fut def _call_sync_method_single( self, method: collections.abc.Callable[..., typing.Any], signature: VarlinkMethodSignature, pyparams: dict[str, typing.Any], oneway: bool, ) -> asyncio.Future[None] | None: result = method(**pyparams) assert isinstance(result, AnnotatedResult) assert not result.continues if oneway: return None with FileDescriptorArray.new_managed() as fds: jsonparams = signature.return_type.tojson( result.parameters, {FileDescriptorVarlinkType: fds} ) fut = self.send_reply( VarlinkMethodReply(jsonparams, extensions=result.extensions), [fd.fileno() for fd in fds], autoclose=False, ) fds.reference_until_done(fut) return fut async def _call_sync_method_more( self, method: collections.abc.Callable[..., typing.Any], signature: VarlinkMethodSignature, pyparams: dict[str, typing.Any], ) -> None: try: continues = True generator = method(**pyparams) for result in generator: assert continues assert isinstance(result, AnnotatedResult) with FileDescriptorArray.new_managed() as fds: jsonparams = signature.return_type.tojson( result.parameters, {FileDescriptorVarlinkType: fds} ) fut = self.send_reply( VarlinkMethodReply( jsonparams, continues=result.continues, extensions=result.extensions, ), [fd.fileno() for fd in fds], autoclose=False, ) fds.reference_until_done(fut) try: await fut except OSError: generator.close() return continues = result.continues assert not continues except VarlinkErrorReply as err: self.send_error(err) async def _call_async_method_single( self, method: collections.abc.Callable[..., typing.Any], signature: VarlinkMethodSignature, pyparams: dict[str, typing.Any], oneway: bool, ) -> None: try: result = await method(**pyparams) assert isinstance(result, AnnotatedResult) assert not result.continues if oneway: return with FileDescriptorArray.new_managed() as fds: jsonparams = signature.return_type.tojson( result.parameters, {FileDescriptorVarlinkType: fds} ) fut = self.send_reply( VarlinkMethodReply( jsonparams, extensions=result.extensions ), [fd.fileno() for fd in fds], autoclose=False, ) fds.reference_until_done(fut) except VarlinkErrorReply as err: if not oneway: self.send_error(err) async def _call_async_method_more( self, method: collections.abc.Callable[..., typing.Any], signature: VarlinkMethodSignature, pyparams: dict[str, typing.Any], ) -> None: try: continues = True generator = method(**pyparams) async for result in generator: assert continues assert isinstance(result, AnnotatedResult) with FileDescriptorArray.new_managed() as fds: jsonparams = signature.return_type.tojson( result.parameters, {FileDescriptorVarlinkType: fds} ) fut = self.send_reply( VarlinkMethodReply( jsonparams, continues=result.continues, extensions=result.extensions, ), [fd.fileno() for fd in fds], autoclose=False, ) fds.reference_until_done(fut) try: await fut except OSError: generator.aclose() return continues = result.continues assert not continues except VarlinkErrorReply as err: self.send_error(err) helmutg-asyncvarlink-185e456/asyncvarlink/serviceerrors.py000066400000000000000000000030421526214135700241240ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Python classes for errors of the org.varlink.service interface.""" from .error import TypedVarlinkErrorReply class InterfaceNotFound( TypedVarlinkErrorReply, interface="org.varlink.service" ): """A method was called for an interface that is not provided or a method was called with an interface parameter and said interface is not provided. """ class Parameters: interface: str class MethodNotFound(TypedVarlinkErrorReply, interface="org.varlink.service"): """A method was called that is not provided on the named interface.""" class Parameters: method: str class MethodNotImplemented( TypedVarlinkErrorReply, interface="org.varlink.service" ): """The interface defines the requested method, but the service does not implement it. """ class Parameters: method: str class InvalidParameter( TypedVarlinkErrorReply, interface="org.varlink.service" ): """A call parameter could not be validated to the expected type.""" class Parameters: parameter: str class PermissionDenied( TypedVarlinkErrorReply, interface="org.varlink.service" ): """The service refuses to service the request as it denies access.""" class Parameters: pass class ExpectedMore(TypedVarlinkErrorReply, interface="org.varlink.service"): """A method was expecting to be called with "more": true, but was called without. """ class Parameters: pass helmutg-asyncvarlink-185e456/asyncvarlink/serviceinterface.py000066400000000000000000000054131526214135700245540ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Python classes for the org.varlink.service interface.""" import typing from .interface import VarlinkInterface, varlinkmethod from .serverprotocol import VarlinkInterfaceRegistry # The serviceerrors module is split off to avoid circular imports, users should # import its exceptions from this module. from .serviceerrors import ( ExpectedMore, InterfaceNotFound, InvalidParameter, MethodNotFound, MethodNotImplemented, PermissionDenied, ) __all__ = [ "ExpectedMore", "InterfaceNotFound", "InvalidParameter", "MethodNotFound", "MethodNotImplemented", "PermissionDenied", "VarlinkServiceInterface", ] class VarlinkServiceInterface(VarlinkInterface, name="org.varlink.service"): """The Varlink Service Interface is provided by every varlink service. It describes the service and the interfaces it implements. """ errors = ( InterfaceNotFound, MethodNotFound, MethodNotImplemented, InvalidParameter, PermissionDenied, ExpectedMore, ) class _GetInfoResult(typing.TypedDict): vendor: str product: str version: str url: str interfaces: list[str] def __init__( self, vendor: str, product: str, version: str, url: str, registry: VarlinkInterfaceRegistry, *, comments: bool = True, ): """Construct an introspection interface object from the given metadata and a VarlinkInterfaceRegistry for introspection. If comments is enabled (default), __doc__ strings will be included in interface descriptions as comments. """ self._info: VarlinkServiceInterface._GetInfoResult = { "vendor": vendor, "product": product, "version": version, "url": url, "interfaces": [], } self._registry = registry self._comments = comments @varlinkmethod def GetInfo(self) -> _GetInfoResult: """Get a list of all the interfaces a service provides and information about the implementation. """ return self._info | { "interfaces": sorted(iface.name for iface in self._registry) } @varlinkmethod(return_parameter="description") def GetInterfaceDescription(self, *, interface: str) -> str: """Get the description of an interface that is implemented by this service. """ try: iface = self._registry[interface] except KeyError: raise InterfaceNotFound(interface=interface) from None return iface.render_interface_description(comments=self._comments) helmutg-asyncvarlink-185e456/asyncvarlink/types.py000066400000000000000000000346661526214135700224130ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Basic type definitions.""" import asyncio import collections.abc import contextlib import os import re import sys import typing import warnings __all__ = [ "FileDescriptor", "FileDescriptorArray", "FutureCounted", "HasFileno", "JSONObject", "JSONValue", "override", "validate_interface", "validate_name", ] if sys.version_info >= (3, 12): from typing import override else: def override(func): """typing.override stub to be removed when dropping 3.11 support.""" return func JSONValue = typing.Union[ None, bool, float, int, str, list["JSONValue"], "JSONObject" ] JSONObject = dict[str, JSONValue] # pylint: disable=too-few-public-methods # It's that one method we describe. class HasFileno(typing.Protocol): """A typing protocol representing a file-like object and looking up the underlying file descriptor. If it has a close method, it must be compatible with the HasClose protocol. """ def fileno(self) -> int: """Return the underlying file descriptor. When invoked on a closed object, the method may return a negative number or raise a ValueError. """ class HasClose(typing.Protocol): """A typing protocol representing objects that can be closed.""" def close(self) -> None: """Can be called at most once to release resources.""" class HasFilenoAndClose(HasFileno, HasClose, typing.Protocol): """A file-like object supporting both fileno and close.""" def get_fileno(fdlike: int | HasFileno | None) -> int: """Return the underlying file descriptor number of an object that may represent an open file descriptor. Raises a ValueError in case there is no valid file descriptor (e.g. None). """ if fdlike is None: raise ValueError("explicitly invalid file descriptor") try: fileno_meth = getattr(fdlike, "fileno") except AttributeError: if not isinstance(fdlike, int): raise TypeError( f"not a file descriptor: {type(fdlike).__name__}" ) from None if fdlike < 0: raise ValueError("negative file descriptor") from None return fdlike fdlike = fileno_meth() # propagate ValueError if fdlike is None: # This is not covered by the protocol, but we can handle it. raise ValueError("fileno returned None") if not isinstance(fdlike, int): raise TypeError(f"fileno returned a non-int: {type(fdlike).__name__}") if fdlike < 0: raise ValueError("fileno() returned negative") return fdlike class FileDescriptor: """Wrap various file descriptor objects of different types in a recognizable type for using in a varlink interface. """ def __init__(self, fd: int | HasFileno | None, should_close: bool = False): """Wrap a file descriptor object that may be one of an integer, a higher level object providing a fileno method or None representing a invalid or closed file descriptor. Optionally, if the should_close flag is set and the FileDescriptor object is garbage collected without being closed, a warning is logged. """ self.should_close: bool self.fd: int | HasFileno | None if isinstance(fd, int) and fd < 0: fd = None elif isinstance(fd, FileDescriptor): if should_close and fd.should_close: raise RuntimeError( "FileDescriptor references another FileDescriptor and " "both are flagged should_close" ) self.fd = fd.fd else: self.fd = fd self.should_close = should_close @classmethod def make_pipe( cls, inheritable: bool = False, blocking: bool = False ) -> tuple["FileDescriptor", "FileDescriptor"]: """Create a pipe and wrap its ends in the FileDescriptor class.""" flags = 0 if not inheritable: flags |= os.O_CLOEXEC if not blocking: flags |= os.O_NONBLOCK if flags: piper, pipew = os.pipe2(flags) else: piper, pipew = os.pipe() return cls(piper), cls(pipew) def __bool__(self) -> bool: """Indicate whether the object refers to a plausibly open file descriptor. """ try: get_fileno(self.fd) except ValueError: return False return True def fileno(self) -> int: """Return the underlying file descriptor, i.e. self. Raises a ValueError when closed. """ return get_fileno(self.fd) __int__ = fileno def close(self) -> None: """Close the file descriptor. Idempotent. If the underlying file descriptor has a close method, it is used. Otherwise, os.close is used. """ if self.fd is None: return try: try: close = getattr(self.fd, "close") except AttributeError: os.close(self.fileno()) else: close() finally: self.fd = None def release(self) -> None: """Close the file descriptor if it should be closed. Do nothing otherwise. """ if self.should_close: self.close() @override def __eq__(self, other: typing.Any) -> bool: """Compare two file descriptors. Comparison to integers, None or objects with a fileno method may succeed. Ownership is not considered for comparison. """ try: selffileno = self.fileno() except ValueError: selffileno = None try: otherfileno = get_fileno(other) except TypeError: return NotImplemented except ValueError: return selffileno is None return selffileno == otherfileno def __enter__(self) -> typing.Self: """Implement the context manager protocol yielding self and closing the file descriptor on exit. The object will be marked as being closed. """ self.should_close = True return self def __exit__(self, *_: object) -> None: """Close the file descriptor on context manager exit.""" self.close() def take(self) -> HasFileno | int | None: """Return and disassociate the wrapped file descriptor object. The FileDescriptor must be responsible for closing and thus marked with the should_close flag. This responsibility is transferred to the caller. """ if not self.should_close: warnings.warn( f"unowned FileDescriptor {self.fd!r} being taken", ResourceWarning, stacklevel=2, source=self, ) try: return self.fd finally: self.fd = None def __del__(self) -> None: """If the FileDescriptor is marked with the should_close flag, close it on garbage collection and issue a warning about closing it explicitly. """ if self.fd is None or not self.should_close: return warnings.warn( f"owned FileDescriptor {self.fd!r} was never closed explicitly", ResourceWarning, source=self, ) self.close() @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self.fd!r})" class FutureCounted: """A reference counting base class. References are not simply counted. Instead referees are tracked individually. Any referee must be released eventually by calling release. Once all referees are gone, the destroy method is called once. """ def __init__(self, initial: typing.Any) -> None: """The constructor consumes an initial referee. Otherwise, it would be immediately destroyed. """ self._references: set[int] = {id(initial)} @classmethod @contextlib.contextmanager def new_managed(cls) -> collections.abc.Iterator[typing.Self]: """Create a managed object whose lifetime is managed in a context manager. """ sentinel = object() obj = cls(sentinel) try: yield obj finally: obj.release(sentinel) def reference(self, referee: typing.Any) -> None: """Record an object as referee. The referee should be either passed to release once or garbage collected by Python. """ if not self._references: raise RuntimeError("cannot reference destroyed object") objid = id(referee) assert objid not in self._references self._references.add(objid) def reference_until_done(self, fut: asyncio.Future[typing.Any]) -> None: """Reference this object until the passed future is done.""" self.reference(fut) fut.add_done_callback(self.release) def release(self, referee: typing.Any) -> None: """Release the reference identified by the given referee. If this was the last reference, this object is destroyed. Releasing a referee that was not referenced is an error as is releasing a referee twice. """ objid = id(referee) try: self._references.remove(objid) except KeyError: raise RuntimeError( f"releasing reference to unregistered object {referee!r}" ) from None if not self._references: self.destroy() def destroy(self) -> None: """Called when the last reference is released.""" raise NotImplementedError class FileDescriptorArray(FutureCounted): """Represent an array of observed or owned file descriptors. When the array is released, the contained file descriptors are released which amounts to closing the owned ones. The lifetime can be controlled in two ways. Responsibility for individual file descriptors can be assumed by using the take method and thus removing them from the array. The lifetime of the entire array can be extended using the FutureCounted mechanism inherited from. Each file descriptor in the array must be unique. """ def __init__( self, initial_referee: typing.Any, fds: collections.abc.Iterable[HasFileno | int | None] | None = None, ): """Construct a FileDescriptorArray. The initial_referee is passed to the FutureCounted constructor. The fds iterable must not contain any fileno several times. Note that file descriptors not wrapped in FileDescriptor are assumed to be owned and shall be closed. """ super().__init__(initial_referee) self._by_position: list[FileDescriptor] = [] self._by_fileno: dict[int, int] = {} for fdlike in fds or (): try: fileno = get_fileno(fdlike) except ValueError: fileno = None if fileno is None: self._by_position.append(FileDescriptor(None)) elif fileno in self._by_fileno: raise ValueError( f"file descriptor {fileno} passed several times" ) else: fd = FileDescriptor(fdlike, should_close=True) if fileno is not None: self._by_fileno[fileno] = len(self._by_position) self._by_position.append(fd) def __bool__(self) -> bool: """Are there any owned file descriptors in the array?""" return any(self._by_position) @override def __eq__(self, other: object) -> bool: if not isinstance(other, FileDescriptorArray): return NotImplemented if len(self._by_position) != len(other._by_position): return False return all( fd1 == fd2 for fd1, fd2 in zip(self._by_position, other._by_position) ) def __len__(self) -> int: return len(self._by_position) def __getitem__(self, index: int) -> FileDescriptor: return self._by_position[index] def __iter__(self) -> collections.abc.Iterator[FileDescriptor]: return iter(self._by_position) @override def destroy(self) -> None: """Release all file descriptors. This amounts to closing owned ones. Idempotent. """ for fd in self: fd.release() def add(self, fdlike: HasFileno | int | None) -> int: """Append a file descriptor to the array and return its position. If None or a closed file descriptor are passed, a ValueError is raised. If the file descriptor already is in the array, the add call must pass the same representation (equal integer or identical object) and the existing index is returned. Descriptors not wrapped in the FileDescriptor class are closed eventually. """ fileno = get_fileno(fdlike) # Propagate ValueError and TypeError try: index = self._by_fileno[fileno] except KeyError: if not isinstance(fdlike, FileDescriptor): fdlike = FileDescriptor(fdlike, True) index = len(self._by_position) self._by_position.append(fdlike) self._by_fileno[fileno] = index return index other = self._by_position[index] if other is fdlike or other.fd is fdlike or isinstance(fdlike, int): return index raise ValueError( f"attempt to add {fileno} with different representation " f"{fdlike!r} than existing entry {other.fd!r}" ) def __del__(self) -> None: fds = [fd for fd in self._by_position if fd.should_close and fd] if not fds: return warnings.warn( "FileDescriptorArray not destroyed before garbage collection", ResourceWarning, source=self, ) for fd in fds: fd.release() def validate_interface(interface: str) -> None: """Validate a varlink interface in reverse-domain notation. May raise a ValueError. """ if not re.match( r"^[A-Za-z](?:-*[A-Za-z0-9])*(?:\.[A-Za-z0-9](?:-*[A-Za-z0-9])*)+$", interface, ): raise ValueError(f"invalid varlink interface {interface!r}") def validate_name(name: str) -> None: """Validate a varlink name. May raise a ValueError.""" if not re.match(r"^[A-Z][A-Za-z0-9]*$", name): raise ValueError(f"invalid varlink name {name!r}") helmutg-asyncvarlink-185e456/asyncvarlink/util.py000066400000000000000000000171161526214135700222130ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later """Utility functions that don't fit well elsewhere.""" import asyncio import collections.abc import contextlib import os import socket import stat import typing import weakref from .protocol import _BLOCKING_ERRNOS, VarlinkBaseProtocol, VarlinkTransport from .types import FileDescriptor, override _T = typing.TypeVar("_T") @typing.overload def completing_future() -> ( contextlib.AbstractContextManager[asyncio.Future[None]] ): ... @typing.overload def completing_future( value: _T, ) -> contextlib.AbstractContextManager[asyncio.Future[_T]]: ... @contextlib.contextmanager def completing_future( value: typing.Any = None, ) -> collections.abc.Iterator[asyncio.Future[typing.Any]]: """A context manager that returns a new asyncio.Future which will be done with the passed value on context exit unless the context exits with an exception in which case the future also raises the exception. Even though this is a synchronous context manager, it must be used in an asynchronous context. """ future = asyncio.get_running_loop().create_future() done = False try: yield future except BaseException as exc: future.set_exception(exc) done = True raise finally: if not done: future.set_result(value) # The consumer of the exception typically is not interested in the # actual result, but Python may log an exception unless we retrieve it. future.exception() _P = typing.TypeVar("_P", bound=VarlinkBaseProtocol) async def connect_unix_varlink( protocol_factory: collections.abc.Callable[[], _P], path: os.PathLike[str] | str, *, loop: asyncio.AbstractEventLoop | None = None, inheritable: bool = False, ) -> tuple[VarlinkTransport, _P]: """Connect to the unix domain socket at given path and return a varlink connection. """ if loop is None: loop = asyncio.get_running_loop() socktype = socket.SOCK_STREAM | socket.SOCK_NONBLOCK if not inheritable: socktype |= socket.SOCK_CLOEXEC sock = socket.socket(socket.AF_UNIX, socktype) try: await loop.sock_connect(sock, os.fspath(path)) protocol = protocol_factory() except: sock.close() raise transport = VarlinkTransport(loop, sock, sock, protocol) await asyncio.sleep(0) # wait for all call_soon return transport, protocol class VarlinkUnixServer(asyncio.AbstractServer): """An asyncio server class that will construct VarlinkTransports for accepted connections. """ def __init__( self, sock: socket.socket, protocol_factory: collections.abc.Callable[[], VarlinkBaseProtocol], *, loop: asyncio.AbstractEventLoop | None = None, ): self._loop = asyncio.get_running_loop() if loop is None else loop self._sock: socket.socket | None = sock self._protocol_factory = protocol_factory self._serving: asyncio.Future[None] | None = None self._transports: weakref.WeakSet[VarlinkTransport] = weakref.WeakSet() @override def close(self) -> None: if self._serving is not None: assert self._sock is not None self._loop.remove_reader(self._sock) self._sock.close() self._sock = None self._serving.set_result(None) self._serving = None @override def get_loop(self) -> asyncio.AbstractEventLoop: return self._loop @override def is_serving(self) -> bool: return self._serving is not None @override async def start_serving(self) -> None: if self._serving is None: if self._sock is None: raise ValueError("cannot start serving on closed server") self._serving = self._loop.create_future() self._sock.listen(1) self._loop.add_reader(self._sock, self._handle_accept) def _handle_accept(self) -> None: if self._sock is None: return # closed before handler could run try: conn, addr = self._sock.accept() except OSError as err: if err.errno in _BLOCKING_ERRNOS: return self.close() raise conn.setblocking(False) self._transports.add( VarlinkTransport( self._loop, conn, conn, self._protocol_factory(), {"peername": addr}, ) ) @override async def serve_forever(self) -> None: await self.start_serving() await self.wait_closed() @override async def wait_closed(self) -> None: if self._serving is not None: await self._serving for transport in self._transports.copy(): try: await transport.closed_future except asyncio.CancelledError: pass @override def close_clients(self) -> None: for transport in self._transports.copy(): transport.close() @override def abort_clients(self) -> None: for transport in self._transports.copy(): transport.abort() async def create_unix_server( protocol_factory: collections.abc.Callable[[], VarlinkBaseProtocol], path: os.PathLike[str] | str | None = None, *, loop: asyncio.AbstractEventLoop | None = None, sock: socket.socket | None = None, start_serving: bool = True, inheritable: bool | None = None, ) -> VarlinkUnixServer: """In a similar spirit to asyncio.SelectorEventLoop.create_unix_server create a UNIX domain socket server except that the transport class being used will be VarlinkTransport for being capable of sending and receiving file descriptors. Either the socket (sock) or path must be provided. """ if sock is None: if path is None: raise ValueError("neither path nor sock specified") pathstr = os.fspath(path) socktype = socket.SOCK_STREAM | socket.SOCK_NONBLOCK if not inheritable: socktype |= socket.SOCK_CLOEXEC sock = socket.socket(socket.AF_UNIX, socktype) if not pathstr.startswith("\0"): try: st = os.stat(pathstr) except FileNotFoundError: pass else: if stat.S_ISSOCK(st.st_mode): os.unlink(pathstr) try: sock.bind(pathstr) except: sock.close() raise elif inheritable is not None: raise ValueError("cannot specify both sock and inheritable") else: sock.setblocking(False) server = VarlinkUnixServer(sock, protocol_factory, loop=loop) if start_serving: await server.start_serving() return server def get_listen_fd(name: str) -> FileDescriptor | None: """Obtain a file descriptor using the systemd socket activation protocol. """ try: pid = int(os.environ["LISTEN_PID"]) fds = int(os.environ["LISTEN_FDS"]) except (KeyError, ValueError): return None if fds < 1 or pid != os.getpid(): return None if fds == 1: if os.environ.get("LISTEN_FDNAMES", name) != name: return None return FileDescriptor(3, should_close=True) try: names = os.environ["LISTEN_FDNAMES"].split(":") except KeyError: return None if len(names) != fds: return None try: return FileDescriptor(3 + names.index(name), should_close=True) except ValueError: return None helmutg-asyncvarlink-185e456/conftest.py000066400000000000000000000001131526214135700203440ustar00rootroot00000000000000# The only purpose of this file is allowing pytest to import asyncvarlink. helmutg-asyncvarlink-185e456/docs/000077500000000000000000000000001526214135700171025ustar00rootroot00000000000000helmutg-asyncvarlink-185e456/docs/conf.py000066400000000000000000000000311526214135700203730ustar00rootroot00000000000000project = 'asyncvarlink' helmutg-asyncvarlink-185e456/docs/index.rst000066400000000000000000000031421526214135700207430ustar00rootroot00000000000000asyncvarlink ============ `Varlink`_ is an inter-process communication protocol based on `JSON`_ and bidirectional streams between two processes. There are multiple implementations for various programming languages including the `reference implementation for Python`_. ``asyncvarlink`` is an alternative implementation in pure Python using different design choices. There are three main differences. * As the name implies, it is based on ``asyncio``. If you prefer synchronous interaction, consider wrapping your interaction in ``asyncio.run`` or using the reference implementation. * Interface definitions are modelled as typed Python classes and the actual varlink interface definition is rendered from those classes. In contrast, the reference implementation parses interface definitions. As a consequence, using an existing varlink interface definition requires translating it to a Python class. This may seem like an odd choice initially, but there often are multiple Python types suitable for representing a varlink type. The reference implementation is less flexible and has a fixed type choice. * Even though the `varlink FAQ`_ explicitly renders passing file descriptors out of scope, `systemd`_ uses that feature. Therefore ``asyncvarlink`` implements the same file descriptor passing scheme whereas the reference implementation does not. .. _Varlink: https://varlink.org .. _JSON: https://www.json.org/ .. _reference implementation for Python: https://github.com/varlink/python .. _varlink FAQ: https://varlink.org/FAQ .. _systemd: https://systemd.io Contents -------- .. toctree:: usage structure helmutg-asyncvarlink-185e456/docs/structure.rst000066400000000000000000000130771526214135700217040ustar00rootroot00000000000000Structure ========= On the ``asyncio`` side the basic classes are ``VarlinkTransport`` and ``VarlinkBaseProtocol``. The use of a dedicated transport class is unusual and rooted in two aspects. For one thing, varlink can communicate over pipes where sending and receiving happens on different file descriptors. For another, this library implements file descriptor passing (over sockets) and most transport classes don't do that. As a result, the ``VarlinkBaseProtocol`` hierarchy only works with ``VarlinkTransport`` transport objects. Where usual transports use ``write`` and usual protocols use ``data_received``, these classes use ``VarlinkTransport.send_message`` and ``VarlinkBaseProtocol.message_received`` instead. As a result transport methods ``pause_reading`` and ``resume_reading`` have been retained with unchanged semantics. Other methods such as ``close`` and ``is_closing`` work as with a more common transport. On the protocol side, ``connection_made`` and ``eof_received`` and ``connection_lost`` have the usual protocol semantics. The ``VarlinkProtocol`` class implements the lowest level of parsing and consumes and produces arbitrary JSON objects combined with file descriptors. On top of the lowest level, there are ``VarlinkClientProtocol`` and ``VarlinkServerProtocol``. These classes perform basic structural validation and turn the JSON objects into ``VarlinkMethodCall`` and ``VarlinkMethodReply`` objects or ``VarlinkErrorReply`` exceptions. At this level, any file descriptors are simply passed through. The call and reply parameters still are bare Python objects representing the JSON data with no validation. To move to yet higher level, the ``VarlinkInterface`` base class can be used for representing what varlink calls an `interface`_. While the approach taken by many other implementations is consuming a ``.varlink`` interface description file, this library goes the other way round and one describes an interface as Python code with type hints. An interface definition in the specified syntax can then be derived (e.g. for use with introspection) automatically. To craft your own interface, inherit from ``VarlinkInterface`` and define an interface name. Then use the ``@varlinkmethod`` decorator for all varlink exposed methods, which must be fully type annotated. This decorator derives a varlink-specific type representation from the Python type annotations. File descriptors passed to a varlink method implementation remain valid until the method returns. If an interface is only meant for use with clients, there is no need for implementing its methods. On the client side, methods whose return type contains a ``FileDescriptor`` have their result(s) wrapped in synchronous context managers establishing the initial life time of the contained file descriptors. Instances of ``VarlinkInterface`` subclasses may be collected in a ``VarlinkInterfaceRegistry`` and then passed to a ``VarlinkInterfaceServerProtocol`` to run an IPC server with the given instances. On the client side, a ``VarlinkInterface`` subclass (not instance) can be glued to a ``VarlinkClientProtocol`` instance using a ``VarlinkInterfaceProxy``. It allows accessing varlink methods as if they were regular, asynchronous Python methods. The passing of file descriptors is a bit special. There are two main ways in which file descriptors are conveyed. One is as a ``list[int]``. In those cases, they are typically passed as function arguments and the called function is supposed to observe, but not close them. The file descriptors are supposed to remain valid until the called function returns. The other way uses the ``FileDescriptorArray`` class. When this class is in use, the object actually owns the file descriptors and is responsible for closing them eventually. Typically, the life time of such an array is fairly limited, but it can be extended by passing an ``asyncio.Future`` to its ``reference_until_done`` method to defer closing. Additionally, individual file descriptors may be removed from the array using the ``FileDescriptor.take`` method. When doing so, responsibility for closing a taken file descriptor is transferred to the caller. If using the ``VarlinkInterfaceServerProtocol`` or ``VarlinkInterfaceProxy``, the ``FileDescriptorArray`` object is no longer exposed and the array life time is managed implicitly. On the server side, the array remains valid until the handling method of the ``VarlinkInterface`` implementation returns. When returning file descriptors from such methods, the array is constructed internally and all relevant descriptors must be wrapped using ``FileDescriptor``. The server will invoke ``.release`` on the ``FileDescriptor`` objects after sending and thus close those that were constructed with ``should_close = True``. The type conversion between JSON and Python objects is mostly straight forward. Basic types such as ``bool``, ``int``, ``float`` and ``str`` map trivially. The ``list`` type must be used homogeneously and is traversed while ``dict`` may be used homogeneously or inhomogeneous (as ``typing.TypedDict`` or ``dataclasses.dataclass``) with ``str`` keys in all cases. Values can be made optional in most cases and Python ``set`` is represented as a mapping from strings to empty objects. Subclasses of ``enum.Enum`` are turned into strings and the introspection reports them as enums. File descriptors tagged with the ``FileDescriptor`` wrapper are represented as integer indices into a separately passed array of file descriptors on the transport layer. For details of any of the mentioned classes, please refer to the docstrings e.g. by using ``pydoc``. .. _interface: https://varlink.org/Interface-Definition helmutg-asyncvarlink-185e456/docs/usage.rst000066400000000000000000000151011526214135700207360ustar00rootroot00000000000000Usage ===== Installation ------------ The project is regularly published to PyPI, but you can also install it from git. .. code-block:: console (.venv) $ pip install git+https://github.com/helmutg/asyncvarlink Client ------ In order to use the high-level interface, a varlink interface definition needs a corresponding Python class. For the ``org.varlink.service`` introspection interface, there is a ``VarlinkServiceInterface`` Python class available. We can use it to introspect ``systemd-hostnamed`` for example. .. code-block:: python import asyncio from asyncvarlink import VarlinkClientProtocol, connect_unix_varlink from asyncvarlink.serviceinterface import VarlinkServiceInterface async def main() -> None: transport, protocol = await connect_unix_varlink( VarlinkClientProtocol, "/run/systemd/io.systemd.Hostname" ) interfaceproxy = protocol.make_proxy(VarlinkServiceInterface) print(await interfaceproxy.GetInfo()) transport.close() asyncio.run(main()) ``VarlinkServiceInterface`` is the Python implementation of the introspection interface. For non-introspecting use specify your own interface class here. A proxy object needs to be created for each interface that is being used. Defining an interface --------------------- When using ``asyncvarlink`` a varlink interface definition has to be manually translated to a Python class in order to use the interface with type automatic type conversion. Definitions have to inherit from ``asyncvarlink.VarlinkInterface`` and they have to define their interface name (e.g. via a class inheritance argument ``name``). Methods available to varlink have to be decorated with ``asyncvarlink.varlinkmethod`` and they must be precisely typed. Let's go through the following example. .. code-block:: python import asyncio import collections.abc import enum from asyncvarlink import VarlinkInterface, varlinkmethod class Direction(enum.Enum): left = "left" right = "right" class DemoInterface(VarlinkInterface, name="com.example.demo"): @varlinkmethod(return_parameter="direction") def Reverse(self, *, value: Direction) -> Direction: return Direction.left if value == Direction.right else Direction.right @varlinkmethod(return_parameter="value") def Range(self, *, count: int) -> collections.abc.Iterator[int]: return iter(range(count)) @varlinkmethod async def Sleep(self, *, delay: float) -> None: await asyncio.sleep(delay) A varlink enum may be represented in Python as an ``enum.Enum`` subclass or be typed as a ``typing.Literal``. In this example, we use the former and define a ``Direction`` type. When looking into methods, note that every call parameter and return parameter must have a name. In case of the ``Reverse`` method, a single call parameter named ``value`` is accepted. On the JSON level, the ``Direction`` type is represented as a string, but ``asyncvarlink`` takes care of converting it from and to the ``Direction`` instances. The ``Reverse`` method returns a single value only and therefore its name may be given via the ``return_parameter`` keyword argument to the decorator. The ``Range`` method demonstrates returning multiple values. A caller of this method must supply the ``more`` keyword. Each integer from the requested range is returned individually as a return parameter named ``value``. The last example demonstrates that method definitions may be asynchronous and delay execution. The special case of returning ``None`` indicates that there are no return parameters and an empty object is returned on the JSON level. We may turn this Python class into a textual varlink interface definition suitable for consumption by other varlink implementations or the introspection interface. .. code-block:: python print(DemoInterface.render_interface_description()) It will result in the following interface definition. .. code-block:: text interface com.example.demo type Direction (left, right) method Range(count: int) -> (value: object) method Reverse(value: Direction) -> (direction: Direction) method Sleep(delay: float) -> () When using ``VarlinkInterface`` subclasses with a client, they are not instantiated and their methods are never called. Therefore, methods may be stubbed using dots (``...``). For use in a server, instances need to be supplied as their methods will be invoked upon client request. Server ------ Let's build a simple server that provides the previously defined demonstration interface as well as the generic introspection interface on stdin/stdout. The relevant protocol class does not consume individual interface instances, but an ``asyncvarlink.VarlinkInterfaceRegistry``. It forms a cycle with the introspection interface as the latter wants to know about all available interfaces, but it also is a registered interface. .. code-block:: python from asyncvarlink import VarlinkInterfaceRegistry from asyncvarlink.serviceinterface import VarlinkServiceInterface registry = VarlinkInterfaceRegistry() registry.register_interface( VarlinkServiceInterface( "ExampleVendor", "DemonstrationProduct", "1.0", "https://github.com/helmutg/asyncvarlink", registry, ), ) registry.register_interface(DemoInterface()) Once the registry is constructed, the communication channel may be established. We attempt to look up an inherited file descriptor named ``varlink`` via systemd's socket activation protocol falling back to communication via stdin and stdout. This is how `varlinkctl`_ communicates with executed servers. .. code-block:: python import sys from asyncvarlink import VarlinkTransport, VarlinkInterfaceServerProtocol, get_listen_fd async def main(): fut = asyncio.get_running_loop().create_future() protocol = VarlinkInterfaceServerProtocol(registry) protocol.connection_lost = fut.set_result listenfd = get_listen_fd("varlink") VarlinkTransport( asyncio.get_running_loop(), listenfd or sys.stdin.fileno(), listenfd or sys.stdout.fileno(), protocol, ) await fut asyncio.run(main()) This allows us to interact with our toy service. .. code-block:: text $ varlinkctl info ./toy_server.py Vendor: ExampleVendor Product: DemonstrationProduct Version: 1.0 URL: https://github.com/helmutg/asyncvarlink Interfaces: com.example.demo org.varlink.service $ .. _varlinkctl: https://www.freedesktop.org/software/systemd/man/latest/varlinkctl.html helmutg-asyncvarlink-185e456/justfile000066400000000000000000000014571526214135700177310ustar00rootroot00000000000000# Copyright 2026 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later # Collectection of common tasks # List available commands list: just -l # Verify coding style style: black --diff asyncvarlink tests # Delete generated files clean: rm -f .coverage rm -Rf .hypothesis .mypy_cache .pytest_cache __pycache__ docs/build */__pycache__ # Spell check comments spell: codespell asyncvarlink tests # Run the unit test suite and produce a coverage report coverage: pytest --cov=asyncvarlink # Type check tests (fails) testtypecheck: mypy --strict tests # Type check the library typecheck: mypy --strict asyncvarlink # Run pylint on the code lint: pylint asyncvarlink tests # Render the documentation into html doc: sphinx-build docs docs/build # Run unit tests test: pytest helmutg-asyncvarlink-185e456/pyproject.toml000066400000000000000000000025231526214135700210700ustar00rootroot00000000000000[project] name = "asyncvarlink" version = "0.3.3" description = "asyncio-compatible, type-driven varlink implementation supporting file descriptor passing" readme = "README.md" license = "LGPL-2.0-or-later" authors = [{name = "Helmut Grohne", email = "helmut@subdivi.de"}] classifiers = [ "Development Status :: 4 - Beta", "Operating System :: POSIX", "Typing :: Typed", "Programming Language :: Python :: 3", ] requires-python = ">=3.11" [project.optional-dependencies] devel = [ "black", "codespell", "mypy", "pylint", "pytest", "pytest-cov", "sphinx", ] test = ["hypothesis"] [project.urls] homepage = "https://github.com/helmutg/asyncvarlink" github = "https://github.com/helmutg/asyncvarlink" issues = "https://github.com/helmutg/asyncvarlink/issues" documentation = "https://helmutg.github.io/asyncvarlink/" [build-system] requires = ["flit_core >=3.8"] build-backend = "flit_core.buildapi" [tool.black] line-length = 79 [tool.coverage.run] branch = true [tool.coverage.report] exclude_also = [ "raise NotImplementedError", "if typing.TYPE_CHECKING:", ] [tool.flit.module] name = "asyncvarlink" [tool.flit.sdist] include = ["conftest.py", "docs/", "LICENSE", "README.md", "tests/"] [tool.mypy] strict = true enable_error_code = "explicit-override" [tool.pylint] max-line-length = 79 good-names = ["fd"] helmutg-asyncvarlink-185e456/tests/000077500000000000000000000000001526214135700173145ustar00rootroot00000000000000helmutg-asyncvarlink-185e456/tests/helpers.py000066400000000000000000000054361526214135700213400ustar00rootroot00000000000000# Copyright 2026 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import asyncio import os import socket import unittest import unittest.mock from asyncvarlink.types import override def async_read_fd(fd: int, size: int) -> asyncio.Future[bytes]: loop = asyncio.get_running_loop() fut = loop.create_future() def readable() -> None: loop.remove_reader(fd) try: data = os.read(fd, size) except Exception as exc: fut.set_exception(exc) else: fut.set_result(data) loop.add_reader(fd, readable) return fut def _send_fds_writable( sock: socket.socket, data: bytes, fds: list[int], fut: asyncio.Future[None] ) -> None: loop = asyncio.get_running_loop() try: sent = socket.send_fds(sock, [data], fds) except Exception as exc: loop.remove_writer(sock) fut.set_exception(exc) else: if sent >= len(data): loop.remove_writer(sock) fut.set_result(None) else: loop.add_writer( sock, _send_fds_writable, sock, data[sent:], [], fut ) def async_send_fds( sock: socket.socket, data: bytes, fds: list[int] | None = None ) -> asyncio.Future[None]: loop = asyncio.get_running_loop() if fds is None: fds = [] fut = loop.create_future() loop.add_writer(sock, _send_fds_writable, sock, data, fds, fut) return fut async def defer( count: int = 100, until_called: unittest.mock.Mock | None = None ) -> None: for _ in range(count): if until_called is not None and until_called.called: return await asyncio.sleep(0) class StrictAsyncioTestCase(unittest.IsolatedAsyncioTestCase): """Allow writing asyncio-based test cases like unittest.IsolatedAsyncioTestCase, but also fail if any exception escapes from a callback. """ def _exception_handler( self, loop: asyncio.AbstractEventLoop, context: dict[str, object] ) -> None: self._observed_exceptions.append(context) @override async def asyncSetUp(self) -> None: await super().asyncSetUp() self._observed_exceptions: list[dict[str, object]] = [] asyncio.get_running_loop().set_exception_handler( self._exception_handler ) @override async def asyncTearDown(self) -> None: count = len(self._observed_exceptions) if count > 0: if count == 1: self.fail( f"An exception escaped from a callback: {self._observed_exceptions[0]!r}" ) else: self.fail( f"{count} exceptions escaped from callbacks {self._observed_exceptions!r}" ) await super().asyncTearDown() helmutg-asyncvarlink-185e456/tests/test_certification.py000066400000000000000000000251771526214135700235640ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2026 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import argparse import asyncio import dataclasses import math import secrets import socket import sys import typing if __file__.split("/")[-2:-1] == ["tests"]: sys.path.insert(0, "/".join(__file__.split("/")[:-2])) from asyncvarlink import ( connect_unix_varlink, create_unix_server, TypedVarlinkErrorReply, VarlinkClientProtocol, VarlinkInterface, VarlinkInterfaceRegistry, varlinkmethod, VarlinkTransport, ) from asyncvarlink.serviceinterface import VarlinkServiceInterface from helpers import StrictAsyncioTestCase class ResultTest05(typing.TypedDict): bool: bool int: int float: float string: str class ResultTest06(typing.TypedDict): struct: ResultTest05 class Anon(typing.TypedDict): foo: bool bar: bool @dataclasses.dataclass class Interface: foo: list[dict[str, typing.Literal["foo", "bar", "baz"]] | None] | None anon: Anon class FirstSecondStruct(typing.TypedDict): first: int second: str @dataclasses.dataclass class MyType: object: object enum: typing.Literal["one", "two", "three"] struct: FirstSecondStruct array: list[str] dictionary: dict[str, str] stringset: set[str] nullable: str | None nullable_array_struct: list[FirstSecondStruct] | None interface: Interface class ClientIdError( TypedVarlinkErrorReply, interface="org.varlink.certification" ): class Parameters: pass class CertificationError( TypedVarlinkErrorReply, interface="org.varlink.certification" ): class Parameters: wants: str got: str class CertificationInterface( VarlinkInterface, name="org.varlink.certification" ): errors = (ClientIdError, CertificationError) def __init__(self) -> None: self.next_method: dict[str, str] = {} def check_equal(self, wants: typing.Any, got: typing.Any) -> None: if wants != got: if isinstance(wants, str) and isinstance(got, str): raise CertificationError(wants=wants, got=got) raise CertificationError(wants=repr(wants), got=repr(got)) def check_method( self, client_id: str, cur_meth: str, next_meth: str ) -> None: try: exp_meth = self.next_method[client_id] except KeyError: raise CertificationError( wants="org.varlink.certification.Start", got="org.varlink.certification." + cur_meth, ) from None self.check_equal( "org.varlink.certification." + exp_meth, "org.varlink.certification." + cur_meth, ) self.next_method[client_id] = next_meth @varlinkmethod(return_parameter="client_id") def Start(self) -> str: client_id = secrets.token_hex() self.next_method[client_id] = "Test01" return client_id @varlinkmethod(return_parameter="bool") def Test01(self, client_id: str) -> bool: self.check_method(client_id, "Test01", "Test02") return True @varlinkmethod(return_parameter="int") def Test02(self, client_id: str, bool: bool) -> int: self.check_method(client_id, "Test02", "Test03") self.check_equal(True, bool) return 1 @varlinkmethod(return_parameter="float") def Test03(self, client_id: str, int: int) -> float: self.check_method(client_id, "Test03", "Test04") self.check_equal(1, int) return 1.0 @varlinkmethod(return_parameter="string") def Test04(self, client_id: str, float: float) -> str: self.check_method(client_id, "Test04", "Test05") self.check_equal(1.0, float) return "ping" @varlinkmethod def Test05(self, client_id: str, string: str) -> ResultTest05: self.check_method(client_id, "Test05", "Test06") self.check_equal("ping", string) return { "bool": False, "int": 2, "float": math.pi, "string": "a lot of string", } @varlinkmethod def Test06( self, client_id: str, bool: bool, int: int, float: float, string: str ) -> ResultTest06: self.check_method(client_id, "Test06", "Test07") self.check_equal(False, bool) self.check_equal(2, int) self.check_equal(math.pi, float) self.check_equal("a lot of string", string) return { "struct": { "bool": False, "int": 2, "float": math.pi, "string": "a lot of string", }, } @varlinkmethod(return_parameter="map") def Test07(self, client_id: str, struct: ResultTest05) -> dict[str, str]: self.check_method(client_id, "Test07", "Test08") self.check_equal( { "bool": False, "int": 2, "float": math.pi, "string": "a lot of string", }, struct, ) return {"foo": "Foo", "bar": "Bar"} @varlinkmethod(return_parameter="set") def Test08(self, client_id: str, map: dict[str, str]) -> set[str]: self.check_method(client_id, "Test08", "Test09") self.check_equal({"foo": "Foo", "bar": "Bar"}, map) return {"one", "two", "three"} @varlinkmethod(return_parameter="mytype", allow_foreign=True) def Test09(self, client_id: str, set: set[str]) -> MyType: self.check_method(client_id, "Test09", "Test10") self.check_equal({"one", "two", "three"}, set) return MyType( object={ "method": "org.varlink.certification.Test09", "parameters": {"map": {"foo": "Foo", "bar": "Bar"}}, }, enum="two", struct={"first": 1, "second": "2"}, array=["one", "two", "three"], dictionary={"foo": "Foo", "bar": "Bar"}, stringset={"one", "two", "three"}, nullable=None, nullable_array_struct=None, interface=Interface( foo=[ None, {"foo": "foo", "bar": "bar"}, None, {"one": "foo", "two": "bar"}, ], anon={"foo": True, "bar": False}, ), ) @varlinkmethod(return_parameter="string", allow_foreign=True) def Test10(self, client_id: str, mytype: MyType) -> typing.Iterator[str]: self.check_equal( { "method": "org.varlink.certification.Test09", "parameters": {"map": {"foo": "Foo", "bar": "Bar"}}, }, mytype.object, ) self.check_equal("two", mytype.enum) self.check_equal({"first": 1, "second": "2"}, mytype.struct) self.check_equal(["one", "two", "three"], mytype.array) self.check_equal({"foo": "Foo", "bar": "Bar"}, mytype.dictionary) self.check_equal({"one", "two", "three"}, mytype.stringset) self.check_equal(None, mytype.nullable) self.check_equal(None, mytype.nullable_array_struct) self.check_equal( Interface( foo=[ None, {"foo": "foo", "bar": "bar"}, None, {"one": "foo", "two": "bar"}, ], anon={"foo": True, "bar": False}, ), mytype.interface, ) self.check_method(client_id, "Test10", "Test11") for i in range(1, 11): yield f"Reply number {i}" @varlinkmethod def Test11(self, client_id: str, last_more_replies: list[str]) -> None: '''must be called as "oneway"''' self.check_method(client_id, "Test11", "End") self.check_equal( [f"Reply number {i}" for i in range(1, 11)], last_more_replies ) @varlinkmethod(return_parameter="all_ok") def End(self, client_id: str) -> bool: self.check_method(client_id, "End", "Start") del self.next_method[client_id] return True def make_registry() -> VarlinkInterfaceRegistry: registry = VarlinkInterfaceRegistry() registry.register_interface( VarlinkServiceInterface( "asyncvarlink", "certification_server", "0", "https://github.com/helmutg/asyncvarlink", registry, ), ) registry.register_interface(CertificationInterface()) return registry async def run_test(protocol: VarlinkClientProtocol) -> None: proxy = protocol.make_proxy(CertificationInterface) res = await proxy.Start() cid = res["client_id"] res = await proxy.Test01(client_id=cid) res = await proxy.Test02(client_id=cid, **res) res = await proxy.Test03(client_id=cid, **res) res = await proxy.Test04(client_id=cid, **res) res = await proxy.Test05(client_id=cid, **res) res = await proxy.Test06(client_id=cid, **res) res = await proxy.Test07(client_id=cid, **res) res = await proxy.Test08(client_id=cid, **res) res = await proxy.Test09(client_id=cid, **res) array = [] async for res in proxy.Test10(client_id=cid, **res): array.append(res["string"]) await proxy.Test11.oneway(client_id=cid, last_more_replies=array) if not await proxy.End(client_id=cid): raise RuntimeError("certification failed") async def selftest() -> None: loop = asyncio.get_running_loop() sock1, sock2 = socket.socketpair() sp = make_registry().protocol_factory() st = VarlinkTransport(loop, sock1, sock1, sp) cp = VarlinkClientProtocol() ct = VarlinkTransport(loop, sock2, sock2, cp) await asyncio.sleep(0) await run_test(cp) ct.close() st.close() class CertificationTests(StrictAsyncioTestCase): async def test_self(self) -> None: await selftest() async def client_main(sock: str) -> None: _, p = await connect_unix_varlink(VarlinkClientProtocol, sock) assert isinstance(p, VarlinkClientProtocol) await run_test(p) print("Certification passed") async def server_main(sock: str) -> None: await ( await create_unix_server( make_registry().protocol_factory, sock, loop=asyncio.get_running_loop(), ) ).serve_forever() def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--client", action="store_true") parser.add_argument("--socket", action="store") args = parser.parse_args() if not args.socket: asyncio.run(selftest()) print("Certification passed") elif args.client: asyncio.run(client_main(args.socket)) else: asyncio.run(server_main(args.socket)) if __name__ == "__main__": main() helmutg-asyncvarlink-185e456/tests/test_clientprotocol.py000066400000000000000000000215751526214135700237770ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import asyncio import collections.abc import json import os import socket from asyncvarlink import ( FileDescriptor, TypedVarlinkErrorReply, VarlinkClientProtocol, VarlinkInterface, VarlinkTransport, varlinkmethod, ) from asyncvarlink.conversion import ConversionError from asyncvarlink.serviceinterface import ( PermissionDenied, VarlinkServiceInterface, ) from asyncvarlink.types import override from helpers import async_read_fd, async_send_fds, StrictAsyncioTestCase class DemoFailure(TypedVarlinkErrorReply): name = "com.example.demo.DemoFailure" class Parameters: pass class DemoInterface(VarlinkInterface, name="com.example.demo"): errors = (DemoFailure,) + VarlinkServiceInterface.errors @varlinkmethod(return_parameter="result") def Method(self, argument: str) -> str: ... @varlinkmethod(return_parameter="result") def MoreMethod(self) -> collections.abc.Iterator[str]: ... @varlinkmethod(return_parameter="fd") def CreateFd(self) -> FileDescriptor: ... class ClientTests(StrictAsyncioTestCase): @override async def asyncSetUp(self) -> None: await super().asyncSetUp() self.loop = asyncio.get_running_loop() self.sock1, self.sock2 = socket.socketpair( type=socket.SOCK_STREAM | socket.SOCK_NONBLOCK ) self.proto = VarlinkClientProtocol() self.transport = VarlinkTransport( self.loop, self.sock2, self.sock2, self.proto ) self.proxy = self.proto.make_proxy(DemoInterface) @override async def asyncTearDown(self) -> None: self.transport.close() await asyncio.sleep(0) self.assertLess(self.sock2.fileno(), 0) self.sock1.close() await super().asyncTearDown() async def expect_data(self, expected: bytes) -> None: data = await self.loop.sock_recv(self.sock1, len(expected) + 1) self.assertEqual(data, expected) async def test_simple(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await self.expect_data( b'{"method":"com.example.demo.Method","parameters":{"argument":"spam"}}\0' ) self.assertFalse(fut.done()) await async_send_fds(self.sock1, b'{"parameters":{"result":"egg"}}\0') self.assertEqual(await fut, {"result": "egg"}) async def test_more(self) -> None: gen = self.proxy.MoreMethod() fut = asyncio.ensure_future(anext(gen)) await self.expect_data( b'{"method":"com.example.demo.MoreMethod","more":true}\0' ) self.assertFalse(fut.done()) await async_send_fds( self.sock1, b'{"continues":true,"parameters":{"result":"spam"}}\0' ) self.assertEqual(await fut, {"result": "spam"}) fut = asyncio.ensure_future(anext(gen)) await asyncio.sleep(0) self.assertFalse(fut.done()) await async_send_fds(self.sock1, b'{"parameters":{"result":"egg"}}\0') self.assertEqual(await fut, {"result": "egg"}) with self.assertRaises(StopAsyncIteration): await anext(gen) async def test_fragmentation(self) -> None: fut1 = asyncio.ensure_future(self.proxy.Method(argument="first")) await self.expect_data( b'{"method":"com.example.demo.Method","parameters":{"argument":"first"}}\0' ) await async_send_fds(self.sock1, b'{"parameters":') fut2 = asyncio.ensure_future(self.proxy.Method(argument="second")) await self.expect_data( b'{"method":"com.example.demo.Method","parameters":{"argument":"second"}}\0' ) self.assertFalse(fut1.done()) self.assertFalse(fut2.done()) await async_send_fds( self.sock1, b'{"result":"one"}}\0{"parameters":{"result":"two"}}\0' ) self.assertEqual(await fut1, {"result": "one"}) self.assertEqual(await fut2, {"result": "two"}) async def test_invalid_argument(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(invalid_argument=True)) await asyncio.sleep(0) self.assertTrue(fut.done()) self.assertRaises(ConversionError, fut.result) async def test_demo_error(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await self.expect_data( b'{"method":"com.example.demo.Method","parameters":{"argument":"spam"}}\0' ) self.assertFalse(fut.done()) await async_send_fds( self.sock1, b'{"error":"com.example.demo.DemoFailure"}\0' ) with self.assertRaises(DemoFailure): await fut async def test_permission_denied(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await self.expect_data( b'{"method":"com.example.demo.Method","parameters":{"argument":"spam"}}\0' ) self.assertFalse(fut.done()) await async_send_fds( self.sock1, b'{"error":"org.varlink.service.PermissionDenied"}\0' ) with self.assertRaises(PermissionDenied): await fut async def test_broken_pipe_send(self) -> None: self.sock1.close() fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) with self.assertRaises(BrokenPipeError): await fut self.assertLess(self.sock2.fileno(), 0) async def test_broken_pipe_receive(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await self.expect_data( b'{"method":"com.example.demo.Method","parameters":{"argument":"spam"}}\0' ) self.assertFalse(fut.done()) self.sock1.close() with self.assertRaises(ConnectionResetError): await fut self.assertLess(self.sock2.fileno(), 0) async def test_non_json_reply(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await async_send_fds(self.sock1, b"this is not json\0") with self.assertRaises(json.JSONDecodeError): await fut async def test_non_object_reply(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await async_send_fds(self.sock1, b'"not an object"\0') with self.assertRaises(TypeError): await fut async def test_missing_fields(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await async_send_fds(self.sock1, b'{"missing fields":0}\0') with self.assertRaises(ConversionError): await fut async def test_return_fd(self) -> None: fut = asyncio.ensure_future(self.proxy.CreateFd()) await self.expect_data(b'{"method":"com.example.demo.CreateFd"}\0') self.assertFalse(fut.done()) rend, wend = os.pipe() self.addCleanup(os.close, rend) read_fut = async_read_fd(rend, 7) await async_send_fds(self.sock1, b'{"parameters":{"fd":0}}\0', [wend]) with await fut as ret: wfd = ret["fd"].take() self.addCleanup(os.close, wfd) os.close(wend) # Defer closure for assertNotEqual self.assertNotEqual(wfd, wend) self.assertFalse(read_fut.done()) os.write(wfd, b"needle") self.assertEqual(await read_fut, b"needle") class ClientPipeTests(StrictAsyncioTestCase): @override async def asyncSetUp(self) -> None: await super().asyncSetUp() self.loop = asyncio.get_running_loop() self.pipers, self.pipewc = os.pipe() self.piperc, self.pipews = os.pipe() os.set_blocking(self.pipers, False) os.set_blocking(self.pipews, False) self.proto = VarlinkClientProtocol() self.transport = VarlinkTransport( self.loop, self.piperc, self.pipewc, self.proto ) self.proxy = self.proto.make_proxy(DemoInterface) async def expect_data(self, expected: bytes) -> None: data = await async_read_fd(self.pipers, len(expected) + 1) self.assertEqual(data, expected) async def test_broken_pipe_send(self) -> None: os.close(self.pipers) fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) with self.assertRaises(BrokenPipeError): await fut with self.assertRaisesRegex(OSError, "Bad file descriptor"): os.close(self.pipewc) async def test_broken_pipe_receive(self) -> None: fut = asyncio.ensure_future(self.proxy.Method(argument="spam")) await self.expect_data( b'{"method":"com.example.demo.Method","parameters":{"argument":"spam"}}\0' ) self.assertFalse(fut.done()) os.close(self.pipews) with self.assertRaises(ConnectionResetError): await fut with self.assertRaisesRegex(OSError, "Bad file descriptor"): os.close(self.pipewc) helmutg-asyncvarlink-185e456/tests/test_conversion.py000066400000000000000000000171771526214135700231270ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import dataclasses import enum import typing import unittest import hypothesis import hypothesis.strategies as st from asyncvarlink import FileDescriptor, FileDescriptorArray from asyncvarlink.conversion import ( ConversionError, DataclassVarlinkType, DictVarlinkType, EnumVarlinkType, FileDescriptorVarlinkType, ForeignVarlinkType, ListVarlinkType, LiteralVarlinkType, ObjectVarlinkType, OptionalVarlinkType, SetVarlinkType, SimpleVarlinkType, VarlinkType, ) from asyncvarlink.types import JSONValue @dataclasses.dataclass class Quantity: value: float unit: str class TriState(enum.Enum): rock = "rock" paper = "paper" scissors = "scissors" class Digits(enum.Enum): zero = "zero" one = "one" two = "two" three = "three" four = "four" five = "five" six = "six" seven = "seven" eight = "eight" nine = "nine" class Dummy: pass @st.deferred def type_annotations() -> st.SearchStrategy[type]: return st.one_of( st.just(bool), st.just(int), st.just(FileDescriptor), st.just(float), st.just(Quantity), st.just(str), st.just(TriState), st.just(typing.Literal["on", "off"]), st.just(Digits), st.just(set[str]), st.builds( lambda fields: typing.TypedDict( "AnonTypedDict", { key: ta if required else typing.NotRequired[ta] for key, (ta, required) in fields.items() }, ), st.dictionaries( st.text(), st.tuples(type_annotations, st.booleans()), ), ), st.builds(lambda ta: ta | None, type_annotations), st.builds(lambda ta: list[ta], type_annotations), st.builds(lambda ta: dict[str, ta], type_annotations), st.builds(lambda: Dummy), ) class MockedFd: """Looks like file descriptor, but isn't and swallows close.""" def __init__(self, fd: int, check_del: bool = False): self.fd = fd self.check_del = check_del def fileno(self) -> int: return self.fd def close(self) -> None: assert self.fd >= 0 self.fd = -1 def __del__(self) -> None: assert self.fd < 0 or not self.check_del # Precreate some fds and do not check their __del__. mocked_fds = [MockedFd(n) for n in range(10)] def representable(vt: VarlinkType) -> st.SearchStrategy[typing.Any]: if isinstance(vt, (SimpleVarlinkType, EnumVarlinkType)): if vt.as_type == float: return st.floats(allow_nan=False) return st.from_type(vt.as_type) if isinstance(vt, LiteralVarlinkType): return st.sampled_from(sorted(vt._values)) if isinstance(vt, FileDescriptorVarlinkType): return st.sampled_from(mocked_fds) if isinstance(vt, OptionalVarlinkType): return st.one_of(st.none(), representable(vt._vtype)) if isinstance(vt, ListVarlinkType): return st.lists(representable(vt._elttype)) if isinstance(vt, DictVarlinkType): return st.dictionaries(st.text(), representable(vt._elttype)) if isinstance(vt, SetVarlinkType): return st.sets(st.text()) if isinstance(vt, ObjectVarlinkType): return st.builds( dict, **{ key: representable(value) for key, value in vt._typemap.items() if not isinstance(value, OptionalVarlinkType) }, ) if isinstance(vt, DataclassVarlinkType): assert vt.as_type is Quantity return st.builds( Quantity, value=st.floats(allow_nan=False), unit=st.from_type(str), ) assert isinstance(vt, ForeignVarlinkType) return st.just(object()) json_values = st.recursive( st.one_of( st.none(), st.booleans(), st.integers(), st.floats(allow_nan=False), st.text(), ), lambda children: st.lists(children) | st.dictionaries(st.text(), children), ) class Coordinate(typing.TypedDict): x: float y: float def close_all_fds(obj: typing.Any) -> None: if isinstance(obj, FileDescriptor): obj.close() elif isinstance(obj, list): for elem in obj: close_all_fds(elem) elif isinstance(obj, dict): for elem in obj.values(): close_all_fds(elem) class ConversionTests(unittest.TestCase): @hypothesis.given(type_annotations, st.data()) def test_round_trip(self, ta: type, data) -> None: vt = VarlinkType.from_type_annotation(ta) obj = data.draw(representable(vt)) sentinel = object() fdarray = FileDescriptorArray(initial_referee=sentinel) oobto: dict[type[VarlinkType], typing.Any] = { FileDescriptorVarlinkType: fdarray } val = vt.tojson(obj, oobto) # We don't have to dispose obj as its MockedFds don't check __del__, # but we want the array to be checked. for fd in fdarray: self.assertIsInstance(fd, FileDescriptor) self.assertIsInstance(fd.fd, MockedFd) assert isinstance(fd.fd, MockedFd) # help mypy fd.fd = MockedFd(fd.fd.fd, check_del=True) oobfrom: dict[type[VarlinkType], typing.Any] = { FileDescriptorVarlinkType: fdarray } obj_again = vt.fromjson(val, oobfrom) self.assertEqual(obj, obj_again) close_all_fds(obj_again) self.assertFalse(bool(fdarray)) fdarray.release(sentinel) @hypothesis.given(type_annotations, json_values) def test_exception(self, ta: type, val: JSONValue) -> None: vt = VarlinkType.from_type_annotation(ta) try: vt.fromjson(val) except ConversionError: pass def test_invalid(self) -> None: testcases: list[tuple[object, list[JSONValue], list[typing.Any]]] = [ (bool, ["fuzzy"], ["fuzzy"]), (int, [2.5], [2.5]), (float, ["fuzzy", 1 << 9999], ["fuzzy"]), (str, [42], [42]), (list[str], [2, [2]], [2, [2]]), (set[str], [2, {"x": 1}], [1, {1}]), (dict[str, str], [2, {"x": 1}], [1, [("x", "y")], {1: 2}]), ( Coordinate, [ 2, {"x": 1.0}, {"x": 1, "y": "bad"}, {"x": 1.0, "y": 1.0, "z": 1.0}, ], [ 2, {"x": 1.0}, {"x": 1, "y": "bad"}, {"x": 1.0, "y": 1.0, "z": 1.0}, ], ), (TriState, [2, "well"], [2, "well"]), (typing.Literal["on", "off"], [2, "maybe"], [2, "maybe"]), (FileDescriptor, ["stdin", -1], ["stdin"]), ] for ta, jvals, pvals in testcases: oobstate: dict[type[VarlinkType], typing.Any] vt = VarlinkType.from_type_annotation(ta) for jval in jvals: oobstate = { FileDescriptorVarlinkType: FileDescriptorArray(self), } with self.subTest(ta=ta, jval=jval): with self.assertRaises(ConversionError): vt.fromjson(jval, oobstate=oobstate) for pval in pvals: with self.subTest(ta=ta, pval=pval): oobstate = {FileDescriptorVarlinkType: []} with self.assertRaises(ConversionError): vt.tojson(pval, oobstate=oobstate) helmutg-asyncvarlink-185e456/tests/test_end2end.py000066400000000000000000000032421526214135700222450ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import contextlib import tempfile from asyncvarlink import ( VarlinkClientProtocol, VarlinkInterface, VarlinkInterfaceRegistry, connect_unix_varlink, create_unix_server, varlinkmethod, ) from helpers import StrictAsyncioTestCase class DummyInterface(VarlinkInterface, name="com.example.Dummy"): def __init__(self) -> None: self.argument = "unset" @varlinkmethod(return_parameter="result") def Method(self, argument: str) -> str: self.argument = argument return "returnvalue" class End2EndTests(StrictAsyncioTestCase): async def test_end2end(self) -> None: registry = VarlinkInterfaceRegistry() interface = DummyInterface() registry.register_interface(interface) async with contextlib.AsyncExitStack() as stack: tdir = stack.enter_context(tempfile.TemporaryDirectory()) sockpath = tdir + "/sock" server = await stack.enter_async_context( await create_unix_server(registry.protocol_factory, sockpath) ) stack.callback(server.close) transport, protocol = await connect_unix_varlink( VarlinkClientProtocol, sockpath ) stack.callback(transport.close) assert isinstance(protocol, VarlinkClientProtocol) proxy = protocol.make_proxy(DummyInterface) self.assertEqual( await proxy.Method(argument="argument"), {"result": "returnvalue"}, ) self.assertEqual(interface.argument, "argument") helmutg-asyncvarlink-185e456/tests/test_interface.py000066400000000000000000000237101526214135700226700ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import collections.abc import typing import unittest from asyncvarlink.interface import ( AnnotatedResult, LastResult, VarlinkInterface, varlinkmethod, ) from helpers import StrictAsyncioTestCase class ResultWrapper(typing.TypedDict): result: str def res(val: str) -> ResultWrapper: return {"result": val} class ExpectedError(Exception): pass class TestInterface(unittest.TestCase): def test_synchronous(self) -> None: class SyncInterface(VarlinkInterface): name = "com.example.SyncInterface" def __init__(self) -> None: self.gen_state = -1 self.genr_state = -1 self.geni_state = -1 self.gene_state = -1 @varlinkmethod def simple(self) -> ResultWrapper: return res("simple") @varlinkmethod(return_type=ResultWrapper) def annotated(self) -> AnnotatedResult: return AnnotatedResult(res("annotated")) @varlinkmethod(return_parameter="result") def named(self) -> str: return "named" @varlinkmethod(return_parameter="result", return_type=str) def annotated_named(self) -> AnnotatedResult: return AnnotatedResult(res("annotated_named")) @varlinkmethod(return_parameter="result") def iterable(self) -> list[int]: return [] @varlinkmethod def error(self) -> ResultWrapper: raise ExpectedError() @varlinkmethod(return_type=ResultWrapper) def gen( self, ) -> collections.abc.Iterator[AnnotatedResult | ResultWrapper]: self.gen_state = 0 yield AnnotatedResult(res("gen0"), continues=True) self.gen_state = 1 yield res("gen1") self.gen_state = 2 yield res("gen2") self.gen_state = 3 @varlinkmethod(return_parameter="result") def gen_raise(self) -> collections.abc.Iterator[str]: self.genr_state = 0 yield "genr0" self.genr_state = 1 raise LastResult("genr1") @varlinkmethod( delay_generator=False, return_parameter="result", return_type=str, ) def gen_immediate( self, ) -> collections.abc.Iterator[AnnotatedResult | str]: self.geni_state = 0 yield AnnotatedResult(res("geni0"), continues=True) self.geni_state = 1 yield "geni1" self.geni_state = 2 raise LastResult("geni2") @varlinkmethod(return_parameter="result") def gen_error(self) -> collections.abc.Iterator[str]: self.gene_state = 0 yield "gene0" self.gene_state = 1 raise ExpectedError() @varlinkmethod def optional(self, *, optional: int | None = None) -> None: if optional is not None: raise ValueError("unexpected optional value") iface = SyncInterface() self.assertEqual(iface.simple(), AnnotatedResult(res("simple"))) self.assertEqual(iface.annotated(), AnnotatedResult(res("annotated"))) self.assertEqual(iface.named(), AnnotatedResult(res("named"))) self.assertEqual( iface.annotated_named(), AnnotatedResult(res("annotated_named")) ) self.assertEqual(iface.iterable(), AnnotatedResult({"result": []})) self.assertRaises(ExpectedError, iface.error) it = iface.gen() self.assertEqual(iface.gen_state, -1) self.assertEqual( next(it), AnnotatedResult(res("gen0"), continues=True) ) self.assertEqual(iface.gen_state, 0) self.assertEqual( next(it), AnnotatedResult(res("gen1"), continues=True) ) self.assertGreater(iface.gen_state, 1) self.assertLess(iface.gen_state, 3) self.assertEqual(next(it), AnnotatedResult(res("gen2"))) self.assertEqual(iface.gen_state, 3) self.assertRaises(StopIteration, next, it) it = iface.gen_raise() self.assertEqual(iface.genr_state, -1) self.assertEqual( next(it), AnnotatedResult(res("genr0"), continues=True) ) self.assertEqual(iface.genr_state, 1) self.assertEqual(next(it), AnnotatedResult(res("genr1"))) self.assertRaises(StopIteration, next, it) it = iface.gen_immediate() self.assertEqual(iface.geni_state, -1) self.assertEqual( next(it), AnnotatedResult(res("geni0"), continues=True) ) self.assertEqual(iface.geni_state, 0) self.assertEqual( next(it), AnnotatedResult(res("geni1"), continues=True) ) self.assertEqual(iface.geni_state, 1) self.assertEqual(next(it), AnnotatedResult(res("geni2"))) self.assertEqual(iface.geni_state, 2) self.assertRaises(StopIteration, next, it) it = iface.gen_error() self.assertEqual(iface.gene_state, -1) self.assertEqual( next(it), AnnotatedResult(res("gene0"), continues=True) ) self.assertEqual(iface.gene_state, 1) self.assertRaises(ExpectedError, next, it) iface.optional() class TestAsyncInterface(StrictAsyncioTestCase): async def test_async(self) -> None: class AsyncInterface(VarlinkInterface): name = "com.example.AsyncInterface" def __init__(self) -> None: self.gen_state = -1 self.genr_state = -1 self.geni_state = -1 @varlinkmethod async def simple(self) -> ResultWrapper: return res("simple") @varlinkmethod(return_type=ResultWrapper) async def annotated(self) -> AnnotatedResult: return AnnotatedResult(res("annotated")) @varlinkmethod(return_parameter="result") async def named(self) -> str: return "named" @varlinkmethod(return_parameter="result", return_type=str) async def annotated_named(self) -> AnnotatedResult: return AnnotatedResult(res("annotated_named")) @varlinkmethod(return_type=ResultWrapper) async def gen( self, ) -> collections.abc.AsyncIterator[ AnnotatedResult | ResultWrapper ]: self.gen_state = 0 yield AnnotatedResult(res("gen0"), continues=True) self.gen_state = 1 yield res("gen1") self.gen_state = 2 yield res("gen2") self.gen_state = 3 @varlinkmethod(return_parameter="result") async def gen_raise(self) -> collections.abc.AsyncIterator[str]: self.genr_state = 0 yield "genr0" self.genr_state = 1 raise LastResult("genr1") @varlinkmethod( delay_generator=False, return_parameter="result", return_type=str, ) async def gen_immediate( self, ) -> collections.abc.AsyncIterator[AnnotatedResult | str]: self.geni_state = 0 yield AnnotatedResult(res("geni0"), continues=True) self.geni_state = 1 yield "geni1" self.geni_state = 2 raise LastResult("geni2") @varlinkmethod async def optional(self, *, optional: int | None = None) -> None: if optional is not None: raise ValueError("unexpected optional value") iface = AsyncInterface() self.assertEqual(await iface.simple(), AnnotatedResult(res("simple"))) self.assertEqual( await iface.annotated(), AnnotatedResult(res("annotated")) ) self.assertEqual(await iface.named(), AnnotatedResult(res("named"))) self.assertEqual( await iface.annotated_named(), AnnotatedResult(res("annotated_named")), ) it = iface.gen() self.assertEqual(iface.gen_state, -1) self.assertEqual( await anext(it), AnnotatedResult(res("gen0"), continues=True) ) self.assertEqual(iface.gen_state, 0) self.assertEqual( await anext(it), AnnotatedResult(res("gen1"), continues=True) ) self.assertGreater(iface.gen_state, 1) self.assertLess(iface.gen_state, 3) self.assertEqual(await anext(it), AnnotatedResult(res("gen2"))) self.assertEqual(iface.gen_state, 3) with self.assertRaises(StopAsyncIteration): await anext(it) it = iface.gen_raise() self.assertEqual(iface.genr_state, -1) self.assertEqual( await anext(it), AnnotatedResult(res("genr0"), continues=True) ) self.assertEqual(iface.genr_state, 1) self.assertEqual(await anext(it), AnnotatedResult(res("genr1"))) with self.assertRaises(StopAsyncIteration): await anext(it) it = iface.gen_immediate() self.assertEqual(iface.geni_state, -1) self.assertEqual( await anext(it), AnnotatedResult(res("geni0"), continues=True) ) self.assertEqual(iface.geni_state, 0) self.assertEqual( await anext(it), AnnotatedResult(res("geni1"), continues=True) ) self.assertEqual(iface.geni_state, 1) self.assertEqual(await anext(it), AnnotatedResult(res("geni2"))) self.assertEqual(iface.geni_state, 2) with self.assertRaises(StopAsyncIteration): await anext(it) await iface.optional() helmutg-asyncvarlink-185e456/tests/test_protocol.py000066400000000000000000000314051526214135700225710ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import asyncio import contextlib import os import socket import unittest import unittest.mock from unittest.mock import Mock from asyncvarlink import ( VarlinkBaseProtocol, VarlinkMethodCall, VarlinkMethodReply, VarlinkProtocol, VarlinkTransport, ) from asyncvarlink.protocol import varlink_boundaries from asyncvarlink.types import JSONObject, JSONValue from helpers import defer, StrictAsyncioTestCase class TransportTests(StrictAsyncioTestCase): async def test_receive_socket(self) -> None: protocol = VarlinkBaseProtocol() protocol.message_received = Mock(return_value=None) protocol.connection_lost = Mock(return_value=None) sock1, sock2 = socket.socketpair() with contextlib.closing(sock1): with contextlib.closing(sock2): transport = VarlinkTransport( asyncio.get_running_loop(), sock1, sock1, protocol ) protocol.eof_received = transport.close sock2.send(b"hello") await defer(until_called=protocol.message_received) protocol.message_received.assert_called_once_with( b"hello", None ) await defer(until_called=protocol.connection_lost) protocol.connection_lost.assert_called_once_with(None) async def test_receive_socket_eof(self) -> None: protocol = VarlinkBaseProtocol() protocol.eof_received = Mock() sock1, sock2 = socket.socketpair() with contextlib.closing(sock1), contextlib.closing(sock2): VarlinkTransport( asyncio.get_running_loop(), sock1, sock1, protocol ) sock2.close() await defer(until_called=protocol.eof_received) protocol.eof_received.assert_called_once_with() async def test_receive_socket_reset(self) -> None: loop = asyncio.get_running_loop() for predictor in (varlink_boundaries, None): with self.subTest(boundary_predictor=predictor): protocol = VarlinkBaseProtocol() protocol.connection_lost = Mock(return_value=None) sock1, sock2 = socket.socketpair() with contextlib.closing( VarlinkTransport( loop, sock1, sock1, protocol, boundary_predictor=predictor, ), ) as transport: await defer() # Closing a socket with unread data makes its peer's # next receive fail with ECONNRESET. sock1.send(b"hello") sock2.close() await asyncio.wait_for(transport.closed_future, 5) protocol.connection_lost.assert_called_once() (exc,) = protocol.connection_lost.call_args.args self.assertIsInstance(exc, ConnectionResetError) async def test_receive_reset_after_send_failed(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkBaseProtocol() protocol.connection_lost = Mock(return_value=None) sock1, sock2 = socket.socketpair() piper, pipew = os.pipe() os.close(piper) with contextlib.closing( VarlinkTransport(loop, sock1, pipew, protocol) ) as transport: await defer() with self.assertRaises(BrokenPipeError): await transport.send_message(b"hello") sock1.send(b"hello") sock2.close() await asyncio.wait_for(transport.closed_future, 5) protocol.connection_lost.assert_called_once() (exc,) = protocol.connection_lost.call_args.args self.assertIsInstance(exc, ConnectionResetError) async def test_receive_pipe(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkBaseProtocol() protocol.message_received = Mock(return_value=None) pipe1, pipe2 = os.pipe() with contextlib.closing( VarlinkTransport(loop, pipe1, pipe2, protocol) ): os.write(pipe2, b"hello") await defer(until_called=protocol.message_received) protocol.message_received.assert_called_once_with(b"hello", None) async def test_receive_pipe_eof(self) -> None: protocol = VarlinkBaseProtocol() protocol.eof_received = Mock() pipe1, pipe2 = os.pipe() VarlinkTransport(asyncio.get_running_loop(), pipe1, pipe2, protocol) os.close(pipe2) await defer(until_called=protocol.eof_received) protocol.eof_received.assert_called_once_with() async def test_send_socket(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkBaseProtocol() sock1, sock2 = socket.socketpair( type=socket.SOCK_STREAM | socket.SOCK_NONBLOCK ) with contextlib.closing(sock1), contextlib.closing(sock2): transport = VarlinkTransport(loop, sock1, sock1, protocol) fut1 = transport.send_message(b"hello") fut2 = transport.send_message(b"world") self.assertEqual(b"helloworld", await loop.sock_recv(sock2, 1024)) self.assertIsNone(fut1.result()) self.assertIsNone(fut2.result()) transport.close() fut3 = transport.send_message(b"fail") with self.assertRaises(OSError): fut3.result() # transport.close can defer closing, but we want it to complete # before exiting the context to avoid exceptions in callbacks. await transport.closed_future async def test_fd_association(self) -> None: protocol = VarlinkBaseProtocol() protocol.message_received = Mock(return_value=None) with contextlib.ExitStack() as stack: sock1, sock2 = socket.socketpair() stack.callback(sock1.close) stack.callback(sock2.close) fd_to_send = os.open("/dev/null", os.O_RDONLY) stack.callback(os.close, fd_to_send) transport = VarlinkTransport( asyncio.get_running_loop(), sock1, sock1, protocol, ) socket.send_fds(sock2, [b"1\0"], []) socket.send_fds(sock2, [b"2\0"], [fd_to_send]) await defer(until_called=protocol.message_received) protocol.message_received.assert_called() self.assertEqual( unittest.mock.call(b"1\0", None), protocol.message_received.call_args_list[0], ) if protocol.message_received.call_count < 2: protocol.message_received.reset_mock() await defer(until_called=protocol.message_received) protocol.message_received.assert_called() call2_data, call2_fds = protocol.message_received.call_args.args self.assertEqual(b"2\0", call2_data) self.assertSequenceEqual([unittest.mock.ANY], call2_fds) class ProtocolTests(StrictAsyncioTestCase): async def test_receive(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkProtocol() pipe1, pipe2 = os.pipe() with contextlib.closing( VarlinkTransport(loop, pipe1, pipe2, protocol) ): protocol.request_received = Mock() protocol.message_received(b'{"hello":"world"}\0', None) await defer(until_called=protocol.request_received) protocol.request_received.assert_called_once_with( {"hello": "world"}, None ) async def test_receive_error(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkProtocol() pipe1, pipe2 = os.pipe() with contextlib.closing( VarlinkTransport(loop, pipe1, pipe2, protocol) ): protocol.error_received = Mock(wraps=protocol.error_received) protocol.message_received(b"}\0", None) await defer(until_called=protocol.error_received) protocol.error_received.assert_called_once() async def test_receive_pause(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkProtocol() pipe1, pipe2 = os.pipe() with contextlib.closing( VarlinkTransport(loop, pipe1, pipe2, protocol) ) as transport: futs = [loop.create_future(), loop.create_future()] protocol.request_received = Mock(side_effect=futs) await defer() self.assertFalse(transport._paused) protocol.message_received(b'{"a":0}\0{"b":0}\0', None) await defer() self.assertTrue(transport._paused) protocol.request_received.assert_called_once_with({"a": 0}, None) self.assertTrue(transport._paused) futs[0].set_result(None) await defer() self.assertTrue(transport._paused) protocol.request_received.assert_called_with({"b": 0}, None) await defer() self.assertTrue(transport._paused) futs[1].set_result(None) await defer() self.assertFalse(transport._paused) async def test_receive_multiple(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkProtocol() pipe1, pipe2 = os.pipe() with contextlib.closing( VarlinkTransport(loop, pipe1, pipe2, protocol) ): protocol.request_received = Mock() await asyncio.sleep(0) protocol.message_received(b'{"a":0}\0{"b":0}\0', None) await defer(until_called=protocol.request_received) protocol.request_received.assert_called_once_with({"a": 0}, None) await asyncio.sleep(0) protocol.request_received.assert_called_with({"b": 0}, None) async def test_send(self) -> None: loop = asyncio.get_running_loop() protocol = VarlinkProtocol() sock1, sock2 = socket.socketpair( type=socket.SOCK_STREAM | socket.SOCK_NONBLOCK ) with ( contextlib.closing(VarlinkTransport(loop, sock1, sock1, protocol)), contextlib.closing(sock2), ): await asyncio.sleep(0) protocol.send_message({"hello": "world"}, []) self.assertEqual( b'{"hello":"world"}\0', await loop.sock_recv(sock2, 1024) ) class CallReplyTest(unittest.TestCase): def test_valid_call(self) -> None: cases: list[JSONObject] = [ {}, {"parameters": {"ingredient": "egg"}}, {"oneway": True}, {"more": True}, {"upgrade": True}, {"more": True}, {"com.example.Extension": "spam"}, ] for jobj in cases: jobj["method"] = "com.example.Spam" call = VarlinkMethodCall.fromjson(jobj) self.assertEqual(jobj, call.tojson()) def test_invalid_call(self) -> None: cases: list[tuple[type[Exception], JSONValue]] = [ (TypeError, 42), (ValueError, {}), (TypeError, {"method": 42}), (ValueError, {"method": "Unqualified"}), (ValueError, {"method": "com.example.lowercase"}), (TypeError, {"method": "com.example.Spam", "oneway": "egg"}), (TypeError, {"method": "com.example.Spam", "more": "egg"}), (TypeError, {"method": "com.example.Spam", "upgrade": "egg"}), ( ValueError, {"method": "com.example.Spam", "more": True, "oneway": True}, ), (TypeError, {"method": "com.example.Spam", "parameters": "egg"}), ] for exc, jobj in cases: with self.assertRaises(exc): VarlinkMethodCall.fromjson(jobj) def test_valid_reply(self) -> None: cases: list[JSONObject] = [ {}, {"error": "com.example.Spam"}, {"parameters": {"ingredients": "egg"}}, {"continues": True}, ] for jobj in cases: reply = VarlinkMethodReply.fromjson(jobj) self.assertEqual(jobj, reply.tojson()) def test_invalid_reply(self) -> None: cases: list[tuple[type[Exception], JSONValue]] = [ (TypeError, 42), (TypeError, {"error": 42}), (ValueError, {"error": "Unqualified"}), (ValueError, {"error": "com.example.lowercase"}), (TypeError, {"parameters": "egg"}), (TypeError, {"continues": "egg"}), ] for exc, jobj in cases: with self.assertRaises(exc): VarlinkMethodReply.fromjson(jobj) helmutg-asyncvarlink-185e456/tests/test_serverprotocol.py000066400000000000000000000312241526214135700240170ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import asyncio import collections.abc import contextlib import json import os import socket import typing from unittest.mock import Mock from asyncvarlink import ( FileDescriptor, TypedVarlinkErrorReply, VarlinkInterface, VarlinkInterfaceRegistry, VarlinkInterfaceServerProtocol, VarlinkTransport, varlinkmethod, ) from asyncvarlink.types import override from helpers import async_read_fd, async_send_fds, defer, StrictAsyncioTestCase class DemoError(TypedVarlinkErrorReply, interface="com.example.demo"): class Parameters: pass class DemoInterface(VarlinkInterface, name="com.example.demo"): def __init__(self, fut: asyncio.Future[int]): super().__init__() self.fut = fut @varlinkmethod(return_parameter="result") def Answer(self) -> int: return 42 @varlinkmethod def Error(self) -> None: raise DemoError() @varlinkmethod async def AsyncError(self) -> None: await asyncio.sleep(0) raise DemoError() @varlinkmethod(return_parameter="result") async def FutureAnswer(self) -> int: return await self.fut @varlinkmethod(return_parameter="result") def SyncMore(self) -> collections.abc.Iterator[int]: yield 1 yield 2 @varlinkmethod(return_parameter="result") def SyncMoreError(self) -> collections.abc.Iterator[int]: yield 1 raise DemoError() @varlinkmethod(return_parameter="result") async def AsyncMore(self) -> collections.abc.AsyncIterator[int]: yield 1 yield 2 @varlinkmethod(return_parameter="fd") async def CreateFd( self, kind: typing.Literal["pipe", "socket"] ) -> FileDescriptor: if kind == "pipe": rend, wend = os.pipe() os.write(wend, b"needle") os.close(wend) return FileDescriptor(rend, True) assert kind == "socket" sock1, sock2 = socket.socketpair() sock1.send(b"needle") sock1.close() return FileDescriptor(sock2, True) @varlinkmethod(return_parameter="content") async def ReadFromSocket(self, fd: FileDescriptor) -> str: fileno = fd.take() assert fileno is not None if not isinstance(fileno, int): fileno = fileno.fileno() with socket.socket(fileno=fileno) as sock: sock.setblocking(False) data = await asyncio.get_running_loop().sock_recv(sock, 1024) return data.decode("utf8") @varlinkmethod(return_parameter="error") def Optional(self, *, optnone: int | None, optdef: int = 42) -> str | None: if optnone is not None: return "optnone is not None" if optdef != 42: return "optdef != 42" return None class ServerTests(StrictAsyncioTestCase): @override async def asyncSetUp(self) -> None: await super().asyncSetUp() self.registry = VarlinkInterfaceRegistry() self.fut = asyncio.get_running_loop().create_future() self.registry.register_interface(DemoInterface(self.fut)) @contextlib.asynccontextmanager async def connected_server( self, ) -> collections.abc.AsyncIterator[tuple[socket.socket, socket.socket]]: loop = asyncio.get_running_loop() sock1, sock2 = socket.socketpair( type=socket.SOCK_STREAM | socket.SOCK_NONBLOCK ) self.protocol = VarlinkInterfaceServerProtocol(self.registry) transport: VarlinkTransport | None = None try: transport = VarlinkTransport(loop, sock2, sock2, self.protocol) yield (sock1, sock2) finally: if transport: transport.close() await asyncio.sleep(0) self.assertLess(sock2.fileno(), 0) else: sock2.close() sock1.close() @contextlib.asynccontextmanager async def piped_server( self, ) -> collections.abc.AsyncIterator[tuple[FileDescriptor, FileDescriptor]]: loop = asyncio.get_running_loop() self.protocol = VarlinkInterfaceServerProtocol(self.registry) pipe1r, pipe1w = FileDescriptor.make_pipe() pipe2r, pipe2w = FileDescriptor.make_pipe() # FileDescriptor makes close idempotent with ( contextlib.closing(pipe1r), contextlib.closing(pipe1w), contextlib.closing(pipe2r), contextlib.closing(pipe2w), ): transport: VarlinkTransport | None = None try: transport = VarlinkTransport( loop, pipe2r, pipe1w, self.protocol ) yield (pipe1r, pipe2w) finally: if transport: transport.close() await asyncio.sleep(0) self.assertFalse(pipe2r) self.assertFalse(pipe1w) def sock_recv_fds( self, sock: socket.socket ) -> asyncio.Future[tuple[bytes, list[int]]]: loop = asyncio.get_running_loop() fut = loop.create_future() loop.add_reader(sock, self._sock_recv_fds, sock, fut) return fut def _sock_recv_fds( self, sock: socket.socket, fut: asyncio.Future[tuple[bytes, list[int]]] ) -> None: asyncio.get_running_loop().remove_reader(sock) try: data, fds, _flags, _addr = socket.recv_fds(sock, 1024, 32) except Exception as exc: fut.set_exception(exc) else: fut.set_result((data, fds)) async def invoke(self, request: bytes, expected_response: bytes) -> None: loop = asyncio.get_running_loop() async with self.connected_server() as (sock1, _): await loop.sock_sendall(sock1, request + b"\0") data = await loop.sock_recv(sock1, 1024) if json.loads(data.split(b"\0", 1)[0]).get("continues"): data += await loop.sock_recv(sock1, 1024) self.assertEqual(data, expected_response + b"\0") async def test_sync_single(self) -> None: await self.invoke( b'{"method":"com.example.demo.Answer"}', b'{"parameters":{"result":42}}', ) async def test_more(self) -> None: await self.invoke( b'{"method":"com.example.demo.SyncMore","more":true}', b'{"continues":true,"parameters":{"result":1}}\0' b'{"parameters":{"result":2}}', ) await self.invoke( b'{"method":"com.example.demo.AsyncMore","more":true}', b'{"continues":true,"parameters":{"result":1}}\0' b'{"parameters":{"result":2}}', ) async def test_error(self) -> None: await self.invoke( b'{"method":"com.example.demo.Error"}', b'{"error":"com.example.demo.DemoError"}', ) await self.invoke( b'{"method":"com.example.demo.SyncMore"}', b'{"error":"org.varlink.service.ExpectedMore"}', ) async def test_async_error(self) -> None: await self.invoke( b'{"method":"com.example.demo.AsyncError"}', b'{"error":"com.example.demo.DemoError"}', ) async def test_more_error(self) -> None: await self.invoke( b'{"method":"com.example.demo.SyncMoreError","more":true}', b'{"continues":true,"parameters":{"result":1}}\0' b'{"error":"com.example.demo.DemoError"}', ) async def test_invalid_interface(self) -> None: await self.invoke( b'{"method":"com.example.doesnotexist.Anything"}', b'{"error":"org.varlink.service.InterfaceNotFound","parameters":{"interface":"com.example.doesnotexist"}}', ) async def test_invalid_method(self) -> None: await self.invoke( b'{"method":"com.example.demo.DoesNotExist"}', b'{"error":"org.varlink.service.MethodNotFound","parameters":{"method":"DoesNotExist"}}', ) async def test_invalid_parameters(self) -> None: await self.invoke( b'{"method":"com.example.demo.Answer","parameters":{"unexpected":1}}', b'{"error":"org.varlink.service.InvalidParameter","parameters":{"parameter":"unexpected"}}', ) async def test_async(self) -> None: self.fut.set_result(42) await self.invoke( b'{"method":"com.example.demo.FutureAnswer"}', b'{"parameters":{"result":42}}', ) async def test_return_fd(self) -> None: loop = asyncio.get_running_loop() async with self.connected_server() as (sock1, sock2): for kind in ("pipe", "socket"): with self.subTest(kind=kind): await loop.sock_sendall( sock1, b'{"method":"com.example.demo.CreateFd","parameters":{"kind":"%s"}}\0' % kind.encode("ascii"), ) data, fds = await self.sock_recv_fds(sock1) self.assertEqual(data, b'{"parameters":{"fd":0}}\0') self.assertEqual(len(fds), 1) data = await async_read_fd(fds[0], 1024) os.close(fds[0]) self.assertEqual(data, b"needle") async def test_pass_fd(self) -> None: loop = asyncio.get_running_loop() async with self.connected_server() as (clsock, srvsock): writesock, readsock = socket.socketpair( type=socket.SOCK_STREAM | socket.SOCK_NONBLOCK ) self.addCleanup(writesock.close) self.addCleanup(readsock.close) await loop.sock_sendall(writesock, b"spam") # This is blocking in theory, but it practically does not block. await async_send_fds( clsock, b'{"method":"com.example.demo.ReadFromSocket","parameters":{"fd": 0}}\0', [readsock.fileno()], ) data, fds = await self.sock_recv_fds(clsock) self.assertEqual(data, b'{"parameters":{"content":"spam"}}\0') self.assertEqual(fds, []) async def test_protocol_violation(self) -> None: await self.invoke( b"{}", b'{"error":"invalid.asyncvarlink.ProtocolViolation"}', ) async def test_broken_socket(self) -> None: loop = asyncio.get_running_loop() async with self.connected_server() as (sock1, sock2): self.protocol.connection_lost = Mock(return_value=None) await loop.sock_sendall( sock1, b'{"method":"com.example.demo.FutureAnswer"}\0' ) sock1.close() await defer(count=10) # Since the future is still pending, the write end is not being # closed yet. self.assertFalse(self.protocol.connection_lost.called) self.fut.set_result(42) await defer(until_called=self.protocol.connection_lost) self.protocol.connection_lost.assert_called_once_with(None) self.assertLess(sock2.fileno(), 0) async def test_pipe_closed(self) -> None: loop = asyncio.get_running_loop() async with self.piped_server() as (rpipe, wpipe): self.protocol.connection_lost = Mock(return_value=None) os.write( wpipe.fileno(), b'{"method":"com.example.demo.FutureAnswer"}\0' ) wpipe.close() await defer(count=10) # Since the future is still pending, the write end is not being # closed yet. self.assertFalse(self.protocol.connection_lost.called) self.fut.set_result(42) data = await async_read_fd(rpipe.fileno(), 1024) self.assertEqual(data, b'{"parameters":{"result":42}}\0') await defer(until_called=self.protocol.connection_lost) self.protocol.connection_lost.assert_called_once_with(None) async def test_broken_pipe(self) -> None: loop = asyncio.get_running_loop() async with self.piped_server() as (rpipe, wpipe): self.protocol.connection_lost = Mock(return_value=None) rpipe.close() os.write( wpipe.fileno(), b'{"method":"com.example.demo.FutureAnswer"}\0' ) wpipe.close() self.fut.set_result(42) await defer(until_called=self.protocol.connection_lost) self.protocol.connection_lost.assert_called_once_with(None) async def test_optional(self) -> None: await self.invoke( b'{"method":"com.example.demo.Optional","parameters":{"optdef":null}}', b'{"parameters":{"error":null}}', ) helmutg-asyncvarlink-185e456/tests/test_serviceinterface.py000066400000000000000000000041041526214135700242450ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import unittest from asyncvarlink import AnnotatedResult, VarlinkInterfaceRegistry from asyncvarlink.types import override from asyncvarlink.serviceinterface import ( InterfaceNotFound, VarlinkServiceInterface, ) class TestServiceInterface(unittest.TestCase): @override def setUp(self) -> None: self.reg = VarlinkInterfaceRegistry() self.vsi = VarlinkServiceInterface( "examplevendor", "exampleproduct", "1.0", "https://example.com", self.reg, comments=False, ) self.reg.register_interface(self.vsi) def test_getinfo(self) -> None: self.assertEqual(self.vsi.name, "org.varlink.service") self.assertEqual( AnnotatedResult( { "vendor": "examplevendor", "product": "exampleproduct", "version": "1.0", "url": "https://example.com", "interfaces": [self.vsi.name], }, ), self.vsi.GetInfo(), ) def test_description(self) -> None: self.assertEqual( AnnotatedResult( { "description": """interface org.varlink.service method GetInfo() -> (vendor: string, product: string, version: string, url: string, interfaces: []string) method GetInterfaceDescription(interface: string) -> (description: string) error InterfaceNotFound (interface: string) error MethodNotFound (method: string) error MethodNotImplemented (method: string) error InvalidParameter (parameter: string) error PermissionDenied () error ExpectedMore () """, }, ), self.vsi.GetInterfaceDescription(interface=self.vsi.name), ) def test_missing_interface(self) -> None: self.assertRaises( InterfaceNotFound, self.vsi.GetInterfaceDescription, interface="com.example.nonexistent", ) helmutg-asyncvarlink-185e456/tests/test_util.py000066400000000000000000000060721526214135700217070ustar00rootroot00000000000000# Copyright 2024 Helmut Grohne # SPDX-License-Identifier: LGPL-2.0-or-later import os import unittest import unittest.mock from asyncvarlink import get_listen_fd class GetListenFdTests(unittest.TestCase): def test_missing(self) -> None: pid = os.getpid() with unittest.mock.patch.dict( "os.environ", {"LISTEN_FDS": "1"}, clear=True ): self.assertIsNone(get_listen_fd("spam"), "missing LISTEN_PID") with unittest.mock.patch.dict( "os.environ", {"LISTEN_FDS": "1", "LISTEN_PID": str(pid + 1)}, clear=True, ): self.assertIsNone(get_listen_fd("spam"), "wrong LISTEN_PID") with unittest.mock.patch.dict( "os.environ", {"LISTEN_FDS": "2", "LISTEN_PID": str(pid)}, clear=True, ): self.assertIsNone(get_listen_fd("spam"), "missing LISTEN_FDNAMES") with unittest.mock.patch.dict( "os.environ", { "LISTEN_FDS": "1", "LISTEN_PID": str(pid), "LISTEN_FDNAMES": "beacon", }, clear=True, ): self.assertIsNone(get_listen_fd("spam"), "mismatch LISTEN_FDNAMES") with unittest.mock.patch.dict( "os.environ", { "LISTEN_FDS": "2", "LISTEN_PID": str(pid), "LISTEN_FDNAMES": "beacon", }, clear=True, ): self.assertIsNone(get_listen_fd("spam"), "bad LISTEN_FDNAMES") with unittest.mock.patch.dict( "os.environ", { "LISTEN_FDS": "2", "LISTEN_PID": str(pid), "LISTEN_FDNAMES": "beacon:egg", }, clear=True, ): self.assertIsNone( get_listen_fd("spam"), "name not in LISTEN_FDNAMES" ) def test_found(self) -> None: pid = str(os.getpid()) with unittest.mock.patch.dict( "os.environ", {"LISTEN_FDS": "1", "LISTEN_PID": pid}, clear=True ): # If there is only one fd and no LISTEN_FDNAMES, expect success for # backwards compatibility. fd = get_listen_fd("spam") self.assertEqual(fd, 3) assert fd is not None # help mypy fd.take() with unittest.mock.patch.dict( "os.environ", {"LISTEN_FDS": "1", "LISTEN_PID": pid, "LISTEN_FDNAMES": "spam"}, clear=True, ): fd = get_listen_fd("spam") self.assertEqual(fd, 3) assert fd is not None # help mypy fd.take() with unittest.mock.patch.dict( "os.environ", { "LISTEN_FDS": "2", "LISTEN_PID": pid, "LISTEN_FDNAMES": "baecon:spam", }, clear=True, ): fd = get_listen_fd("spam") self.assertEqual(fd, 4) assert fd is not None # help mypy fd.take()