pax_global_header 0000666 0000000 0000000 00000000064 15262141357 0014520 g ustar 00root root 0000000 0000000 52 comment=661625032e94f3d95b5f75d21bf661f270f51699
helmutg-asyncvarlink-185e456/ 0000775 0000000 0000000 00000000000 15262141357 0016152 5 ustar 00root root 0000000 0000000 helmutg-asyncvarlink-185e456/.github/ 0000775 0000000 0000000 00000000000 15262141357 0017512 5 ustar 00root root 0000000 0000000 helmutg-asyncvarlink-185e456/.github/workflows/ 0000775 0000000 0000000 00000000000 15262141357 0021547 5 ustar 00root root 0000000 0000000 helmutg-asyncvarlink-185e456/.github/workflows/ci.yaml 0000664 0000000 0000000 00000007144 15262141357 0023034 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000000107 15262141357 0020140 0 ustar 00root root 0000000 0000000 .coverage
.hypothesis
.mypy_cache
.pytest_cache
__pycache__
docs/build
helmutg-asyncvarlink-185e456/HACKING.md 0000664 0000000 0000000 00000003274 15262141357 0017546 0 ustar 00root root 0000000 0000000 # 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/LICENSE 0000664 0000000 0000000 00000061266 15262141357 0017172 0 ustar 00root root 0000000 0000000 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.md 0000664 0000000 0000000 00000007323 15262141357 0017436 0 ustar 00root root 0000000 0000000 asyncvarlink
============
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/ 0000775 0000000 0000000 00000000000 15262141357 0020656 5 ustar 00root root 0000000 0000000 helmutg-asyncvarlink-185e456/asyncvarlink/__init__.py 0000664 0000000 0000000 00000004053 15262141357 0022771 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000035161 15262141357 0024276 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000067016 15262141357 0023427 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000020655 15262141357 0022371 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000055324 15262141357 0023201 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000014372 15262141357 0022663 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000055421 15262141357 0023100 0 ustar 00root root 0000000 0000000 # 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.typed 0000664 0000000 0000000 00000000000 15262141357 0022343 0 ustar 00root root 0000000 0000000 helmutg-asyncvarlink-185e456/asyncvarlink/serverprotocol.py 0000664 0000000 0000000 00000036300 15262141357 0024322 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003042 15262141357 0024124 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005413 15262141357 0024554 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000034666 15262141357 0022413 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000017116 15262141357 0022213 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000000113 15262141357 0020344 0 ustar 00root root 0000000 0000000 # The only purpose of this file is allowing pytest to import asyncvarlink.
helmutg-asyncvarlink-185e456/docs/ 0000775 0000000 0000000 00000000000 15262141357 0017102 5 ustar 00root root 0000000 0000000 helmutg-asyncvarlink-185e456/docs/conf.py 0000664 0000000 0000000 00000000031 15262141357 0020373 0 ustar 00root root 0000000 0000000 project = 'asyncvarlink'
helmutg-asyncvarlink-185e456/docs/index.rst 0000664 0000000 0000000 00000003142 15262141357 0020743 0 ustar 00root root 0000000 0000000 asyncvarlink
============
`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.rst 0000664 0000000 0000000 00000013077 15262141357 0021704 0 ustar 00root root 0000000 0000000 Structure
=========
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.rst 0000664 0000000 0000000 00000015101 15262141357 0020736 0 ustar 00root root 0000000 0000000 Usage
=====
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/justfile 0000664 0000000 0000000 00000001457 15262141357 0017731 0 ustar 00root root 0000000 0000000 # 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.toml 0000664 0000000 0000000 00000002523 15262141357 0021070 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 15262141357 0017314 5 ustar 00root root 0000000 0000000 helmutg-asyncvarlink-185e456/tests/helpers.py 0000664 0000000 0000000 00000005436 15262141357 0021340 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000025177 15262141357 0023564 0 ustar 00root root 0000000 0000000 #!/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.py 0000664 0000000 0000000 00000021575 15262141357 0023777 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000017177 15262141357 0023127 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003242 15262141357 0022245 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000023710 15262141357 0022670 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000031405 15262141357 0022571 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000031224 15262141357 0024017 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004104 15262141357 0024245 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000006072 15262141357 0021707 0 ustar 00root root 0000000 0000000 # 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()