Compare commits
8 Commits
2ced3a81b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
734c3bfff1
|
|||
|
67ca2a8b63
|
|||
|
0b1cd52df1
|
|||
|
f8d45d156b
|
|||
|
dfbb8db2f7
|
|||
|
1118b1a2b2
|
|||
| 15583a3214 | |||
| 53e30dd7c5 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
||||
|
||||
.SyncNode.config
|
||||
/__pycache__
|
||||
*.log
|
||||
secret_key
|
||||
sftp_debug.log
|
||||
sftp_debug.log
|
||||
*.log
|
||||
|
||||
49
ConfigTools.py
Normal file
49
ConfigTools.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from logging import Manager
|
||||
import os
|
||||
from lib.cryptography.fernet import Fernet
|
||||
|
||||
class CredentialManager:
|
||||
def __init__(self, key_file="secret_key"):
|
||||
self.key_file = key_file
|
||||
self.key = self._load_or_generate_key()
|
||||
self.cipher = Fernet(self.key)
|
||||
|
||||
def _load_or_generate_key(self):
|
||||
if not os.path.exists(self.key_file):
|
||||
key = Fernet.generate_key()
|
||||
with open(self.key_file, "wb") as file:
|
||||
file.write(key)
|
||||
os.chmod(self.key_file, 0o600)
|
||||
return key
|
||||
with open(self.key_file, "rb") as file:
|
||||
return file.read()
|
||||
|
||||
def encrypt_password(self, password):
|
||||
return self.cipher.encrypt(password.encode()).decode()
|
||||
|
||||
def dencrypt_password(self, encrypted_password):
|
||||
return self.cipher.decrypt(encrypted_password.encode()).decode()
|
||||
|
||||
|
||||
manager = CredentialManager()
|
||||
|
||||
password = "test1"
|
||||
encrypted = manager.encrypt_password(password)
|
||||
print(f"encrpted: {encrypted}")
|
||||
|
||||
|
||||
decripted = manager.dencrypt_password(encrypted)
|
||||
print(f"Decripted: {decripted}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
22
PKGBUILD
Normal file
22
PKGBUILD
Normal file
@@ -0,0 +1,22 @@
|
||||
pkgname=ServerSync
|
||||
pkgver=1.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="A tool to simply manage & Sync files and directories to remotes with configs!"
|
||||
arch=('any')
|
||||
url="https://github.com/youruser/your-tool"
|
||||
license=('MIT')
|
||||
depends=('bash, python3') # Add runtime dependencies here
|
||||
makedepends=('git' 'gcc') # Add build-time dependencies here
|
||||
source=("https://github.com/youruser/$pkgname/archive/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP') # We will fix this in the next step
|
||||
|
||||
build() {
|
||||
cd "$pkgname-$pkgver"
|
||||
make # Or your specific build command
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname-$pkgver"
|
||||
# This installs the binary to /usr/bin inside the package
|
||||
install -Dm755 your-binary-name "$pkgdir/usr/bin/your-binary-name"
|
||||
}
|
||||
15
README.md
15
README.md
@@ -1,3 +1,16 @@
|
||||
# ServerSync
|
||||
|
||||
Server File Synchronization Tool coupled together by me at 5 AM, youre welcome
|
||||
Server File Synchronization Tool coupled together by me at 5 AM, youre welcome
|
||||
|
||||
Basically :
|
||||
|
||||
- ACTIONS -
|
||||
|
||||
setup <WIP>
|
||||
init - initialise a folder to become a sync node
|
||||
sync - select what files you wanna sync to the server
|
||||
|
||||
- MORE -
|
||||
|
||||
yea there is no more....
|
||||
# also yes passwords ARE STORED IN A TEXT FILE <-----
|
||||
|
||||
@@ -9,7 +9,7 @@ import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(usage="A Quick Tool to Sync Files to Local Servers over FTP", description="Idk , i had ADHD and i was too ored to look up a tool for this job , so yea")
|
||||
|
||||
parser.add_argument("Action", help="The action you wanna perform , do Setup to setup")
|
||||
parser.add_argument("Action", help="The action you wanna perform , do Setup to setup ")
|
||||
parser.add_argument("-l","--log", type=int,help="Set the logging mode")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
|
||||
41
core/scanner.py
Normal file
41
core/scanner.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
import lib.hashlib as hashlib
|
||||
|
||||
|
||||
from lib.pathlib import Path
|
||||
from lib.pathspec import PathSpec
|
||||
|
||||
|
||||
|
||||
class Scanner:
|
||||
def __init__(self, base_path, ignore_file=".syncignore", include_file=".syncinclude"):
|
||||
self.base_path = Path(base_path)
|
||||
self.ignore_file = self._load_ignore_spec(ignore_file)
|
||||
self.include_file= self._load_include_spec(include_file)
|
||||
|
||||
def _load_ignore_spec(self, file):
|
||||
pass
|
||||
|
||||
|
||||
def _load_include_spec(self, file):
|
||||
pass
|
||||
|
||||
def get_current_state(self):
|
||||
state = {}
|
||||
|
||||
for root , dirs, files in os.walk(self.base_path):
|
||||
dirs[:] = [d for d in dirs if not self.spec.match_file(str(Path(root, d).relative_to(self.base_path)))]
|
||||
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
rel_path = str(full_path.relative_to(self.base_path))
|
||||
|
||||
if not self.spec.match_file(rel_path):
|
||||
state[rel_path] = self._hash_file(rel_path)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def _hash_file(self, path)
|
||||
return hashlib.md5(open(path, 'rb').read()).hexdigest()
|
||||
|
||||
77
future.md
Normal file
77
future.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# ServerSync - Future Features & Improvements
|
||||
|
||||
## Security Enhancements
|
||||
- [ ] **Encrypted Credentials Storage** - Replace plaintext password storage with encrypted config (use bcrypt or similar)
|
||||
- Store credentials in system keyring instead of config file
|
||||
- Implement secure credential prompting with masked input
|
||||
- [ ] **SSH Key Authentication** - Support SSH key-based auth as alternative to password
|
||||
- [ ] **Config File Encryption** - Encrypt `.SyncNode.config` files
|
||||
- [ ] **Permission Validation** - Check local file permissions before attempting sync
|
||||
|
||||
## Core Functionality
|
||||
- [ ] **Selective Sync Patterns** - Add `.SyncNodeIgnore` file (like `.gitignore`) to exclude patterns
|
||||
- [ ] **Bidirectional Sync** - Support pulling files from server to local
|
||||
- [ ] **Differential/Delta Sync** - Only sync changed files (hash-based comparison)
|
||||
- [ ] **Dry-run Mode** - Preview what would be synced without actually uploading
|
||||
- [ ] **Resume Interrupted Transfers** - Handle partial uploads and resume capability
|
||||
- [ ] **File Filtering** - Sync only specific file types or sizes
|
||||
|
||||
## Monitoring & Sync Automation
|
||||
- [ ] **Watch Mode** - Monitor local directory and auto-sync on file changes (using watchdog)
|
||||
- [ ] **Scheduled Sync** - Cron-like scheduling for automatic syncs
|
||||
- [ ] **Sync History/Log** - Detailed sync logs with timestamps and status
|
||||
- [ ] **Real-time Notifications** - Terminal/desktop notifications on sync events
|
||||
- [ ] **Conflict Resolution** - Handle file conflicts (overwrite, skip, merge strategies)
|
||||
|
||||
## User Experience
|
||||
- [ ] **Config Management Commands** - Add commands to edit/view/delete configs without re-running init
|
||||
- `ServerSync config list` - List all sync nodes
|
||||
- `ServerSync config edit <name>` - Edit specific node config
|
||||
- `ServerSync config remove <name>` - Remove a config
|
||||
- [ ] **Multi-Node Support** - Manage multiple server profiles
|
||||
- [ ] **Status Command** - `ServerSync status` to check server connection and config
|
||||
- [ ] **Verbose Output** - Better terminal formatting and colored output (improve rich usage)
|
||||
- [ ] **Help System** - Comprehensive help text for each command
|
||||
- [ ] **Interactive Menu** - Main menu to browse options instead of just CLI args
|
||||
|
||||
## Advanced Features
|
||||
- [ ] **Compression** - Compress files before transfer (especially for large batches)
|
||||
- [ ] **Bandwidth Limiting** - Throttle upload speeds to prevent overload
|
||||
- [ ] **Parallel Transfers** - Upload multiple files concurrently
|
||||
- [ ] **Sync Profiles** - Create preset sync configurations
|
||||
- [ ] **Undo/Rollback** - Keep manifest of what was synced and ability to rollback
|
||||
- [ ] **File Permissions Sync** - Mirror file permissions from local to remote
|
||||
- [ ] **Remote Cleanup** - Option to delete remote files that don't exist locally
|
||||
|
||||
## Code Quality
|
||||
- [ ] **Error Handling** - Comprehensive error messages and recovery
|
||||
- [ ] **Unit Tests** - Test coverage for core sync logic
|
||||
- [ ] **Logging Improvements** - Better logging verbosity levels
|
||||
- [ ] **Documentation** - Proper docstrings and API documentation
|
||||
- [ ] **Config Validation** - Validate config before using it
|
||||
- [ ] **Type Hints** - Add Python type annotations throughout
|
||||
|
||||
## Stability & Reliability
|
||||
- [ ] **Connection Pooling** - Reuse SSH connections efficiently
|
||||
- [ ] **Timeout Handling** - Better handling of network timeouts
|
||||
- [ ] **Retry Logic** - Automatic retry with exponential backoff for failed transfers
|
||||
- [ ] **Atomic Operations** - Ensure sync operations are atomic (all or nothing)
|
||||
- [ ] **Lock Files** - Prevent concurrent syncs on same node
|
||||
|
||||
## Distribution & Setup
|
||||
- [ ] **Pip/PyPI Package** - Make installable via pip
|
||||
- [ ] **System Daemon** - Option to run as background service
|
||||
- [ ] **Setup Wizard Completion** - Finish the incomplete `setup.py` implementation
|
||||
- [ ] **Platform Support** - Test on Windows, macOS, Linux fully
|
||||
- [ ] **Requirements File** - Create proper `requirements.txt` or `pyproject.toml`
|
||||
|
||||
## Additional Protocols
|
||||
- [ ] **SCP Support** - Alternative to SFTP
|
||||
- [ ] **AWS S3 Support** - Sync to cloud storage
|
||||
- [ ] **SMB/CIFS Support** - For Windows network shares
|
||||
- [ ] **WebDAV Support** - Support for WebDAV servers
|
||||
|
||||
## Monitoring Dashboard (Future)
|
||||
- [ ] **Web UI** - Simple web interface to manage syncs
|
||||
- [ ] **Metrics** - Track upload speeds, success rates, timing
|
||||
- [ ] **Activity Feed** - See recent sync activities and status
|
||||
1
lib/jaraco.classes-3.4.0.dist-info/INSTALLER
Normal file
1
lib/jaraco.classes-3.4.0.dist-info/INSTALLER
Normal file
@@ -0,0 +1 @@
|
||||
pip
|
||||
17
lib/jaraco.classes-3.4.0.dist-info/LICENSE
Normal file
17
lib/jaraco.classes-3.4.0.dist-info/LICENSE
Normal file
@@ -0,0 +1,17 @@
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
60
lib/jaraco.classes-3.4.0.dist-info/METADATA
Normal file
60
lib/jaraco.classes-3.4.0.dist-info/METADATA
Normal file
@@ -0,0 +1,60 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: jaraco.classes
|
||||
Version: 3.4.0
|
||||
Summary: Utility functions for Python class constructs
|
||||
Home-page: https://github.com/jaraco/jaraco.classes
|
||||
Author: Jason R. Coombs
|
||||
Author-email: jaraco@jaraco.com
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Requires-Python: >=3.8
|
||||
License-File: LICENSE
|
||||
Requires-Dist: more-itertools
|
||||
Provides-Extra: docs
|
||||
Requires-Dist: sphinx >=3.5 ; extra == 'docs'
|
||||
Requires-Dist: jaraco.packaging >=9.3 ; extra == 'docs'
|
||||
Requires-Dist: rst.linker >=1.9 ; extra == 'docs'
|
||||
Requires-Dist: furo ; extra == 'docs'
|
||||
Requires-Dist: sphinx-lint ; extra == 'docs'
|
||||
Requires-Dist: jaraco.tidelift >=1.4 ; extra == 'docs'
|
||||
Provides-Extra: testing
|
||||
Requires-Dist: pytest >=6 ; extra == 'testing'
|
||||
Requires-Dist: pytest-checkdocs >=2.4 ; extra == 'testing'
|
||||
Requires-Dist: pytest-cov ; extra == 'testing'
|
||||
Requires-Dist: pytest-mypy ; extra == 'testing'
|
||||
Requires-Dist: pytest-enabler >=2.2 ; extra == 'testing'
|
||||
Requires-Dist: pytest-ruff >=0.2.1 ; extra == 'testing'
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/jaraco.classes.svg
|
||||
:target: https://pypi.org/project/jaraco.classes
|
||||
|
||||
.. image:: https://img.shields.io/pypi/pyversions/jaraco.classes.svg
|
||||
|
||||
.. image:: https://github.com/jaraco/jaraco.classes/actions/workflows/main.yml/badge.svg
|
||||
:target: https://github.com/jaraco/jaraco.classes/actions?query=workflow%3A%22tests%22
|
||||
:alt: tests
|
||||
|
||||
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json
|
||||
:target: https://github.com/astral-sh/ruff
|
||||
:alt: Ruff
|
||||
|
||||
.. image:: https://readthedocs.org/projects/jaracoclasses/badge/?version=latest
|
||||
:target: https://jaracoclasses.readthedocs.io/en/latest/?badge=latest
|
||||
|
||||
.. image:: https://img.shields.io/badge/skeleton-2024-informational
|
||||
:target: https://blog.jaraco.com/skeleton
|
||||
|
||||
.. image:: https://tidelift.com/badges/package/pypi/jaraco.classes
|
||||
:target: https://tidelift.com/subscription/pkg/pypi-jaraco.classes?utm_source=pypi-jaraco.classes&utm_medium=readme
|
||||
|
||||
For Enterprise
|
||||
==============
|
||||
|
||||
Available as part of the Tidelift Subscription.
|
||||
|
||||
This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use.
|
||||
|
||||
`Learn more <https://tidelift.com/subscription/pkg/pypi-jaraco.classes?utm_source=pypi-jaraco.classes&utm_medium=referral&utm_campaign=github>`_.
|
||||
15
lib/jaraco.classes-3.4.0.dist-info/RECORD
Normal file
15
lib/jaraco.classes-3.4.0.dist-info/RECORD
Normal file
@@ -0,0 +1,15 @@
|
||||
jaraco.classes-3.4.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
jaraco.classes-3.4.0.dist-info/LICENSE,sha256=htoPAa6uRjSKPD1GUZXcHOzN55956HdppkuNoEsqR0E,1023
|
||||
jaraco.classes-3.4.0.dist-info/METADATA,sha256=LmsQIjLt1Frhu4prQJH9QM8yAaa7b9S8l8XozXZaRLg,2623
|
||||
jaraco.classes-3.4.0.dist-info/RECORD,,
|
||||
jaraco.classes-3.4.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
||||
jaraco.classes-3.4.0.dist-info/top_level.txt,sha256=0JnN3LfXH4LIRfXL-QFOGCJzQWZO3ELx4R1d_louoQM,7
|
||||
jaraco/classes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
jaraco/classes/__pycache__/__init__.cpython-314.pyc,,
|
||||
jaraco/classes/__pycache__/ancestry.cpython-314.pyc,,
|
||||
jaraco/classes/__pycache__/meta.cpython-314.pyc,,
|
||||
jaraco/classes/__pycache__/properties.cpython-314.pyc,,
|
||||
jaraco/classes/ancestry.py,sha256=FkU7kyOO-TOMgwR3obcpqB93Ht-f0yxjGnTxcvfBLB0,1787
|
||||
jaraco/classes/meta.py,sha256=uz1zmtse_0n7cs2M2hfz8iIqoe2_2vZI-_JiFvQuDwE,2198
|
||||
jaraco/classes/properties.py,sha256=f-88KCSBeeCliwxfXOwe7Uqk9_elEmi9ZSwOh6_yBq4,6191
|
||||
jaraco/classes/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
5
lib/jaraco.classes-3.4.0.dist-info/WHEEL
Normal file
5
lib/jaraco.classes-3.4.0.dist-info/WHEEL
Normal file
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.43.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
1
lib/jaraco.classes-3.4.0.dist-info/top_level.txt
Normal file
1
lib/jaraco.classes-3.4.0.dist-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
jaraco
|
||||
0
lib/jaraco/classes/__init__.py
Normal file
0
lib/jaraco/classes/__init__.py
Normal file
BIN
lib/jaraco/classes/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lib/jaraco/classes/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jaraco/classes/__pycache__/ancestry.cpython-314.pyc
Normal file
BIN
lib/jaraco/classes/__pycache__/ancestry.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jaraco/classes/__pycache__/meta.cpython-314.pyc
Normal file
BIN
lib/jaraco/classes/__pycache__/meta.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jaraco/classes/__pycache__/properties.cpython-314.pyc
Normal file
BIN
lib/jaraco/classes/__pycache__/properties.cpython-314.pyc
Normal file
Binary file not shown.
76
lib/jaraco/classes/ancestry.py
Normal file
76
lib/jaraco/classes/ancestry.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Routines for obtaining the class names
|
||||
of an object and its parent classes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from more_itertools import unique_everseen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
|
||||
def all_bases(c: type[object]) -> list[type[Any]]:
|
||||
"""
|
||||
return a tuple of all base classes the class c has as a parent.
|
||||
>>> object in all_bases(list)
|
||||
True
|
||||
"""
|
||||
return c.mro()[1:]
|
||||
|
||||
|
||||
def all_classes(c: type[object]) -> list[type[Any]]:
|
||||
"""
|
||||
return a tuple of all classes to which c belongs
|
||||
>>> list in all_classes(list)
|
||||
True
|
||||
"""
|
||||
return c.mro()
|
||||
|
||||
|
||||
# borrowed from
|
||||
# http://code.activestate.com/recipes/576949-find-all-subclasses-of-a-given-class/
|
||||
|
||||
|
||||
def iter_subclasses(cls: type[object]) -> Iterator[type[Any]]:
|
||||
"""
|
||||
Generator over all subclasses of a given class, in depth-first order.
|
||||
|
||||
>>> bool in list(iter_subclasses(int))
|
||||
True
|
||||
>>> class A(object): pass
|
||||
>>> class B(A): pass
|
||||
>>> class C(A): pass
|
||||
>>> class D(B,C): pass
|
||||
>>> class E(D): pass
|
||||
>>>
|
||||
>>> for cls in iter_subclasses(A):
|
||||
... print(cls.__name__)
|
||||
B
|
||||
D
|
||||
E
|
||||
C
|
||||
>>> # get ALL classes currently defined
|
||||
>>> res = [cls.__name__ for cls in iter_subclasses(object)]
|
||||
>>> 'type' in res
|
||||
True
|
||||
>>> 'tuple' in res
|
||||
True
|
||||
>>> len(res) > 100
|
||||
True
|
||||
"""
|
||||
return unique_everseen(_iter_all_subclasses(cls))
|
||||
|
||||
|
||||
def _iter_all_subclasses(cls: type[object]) -> Iterator[type[Any]]:
|
||||
try:
|
||||
subs = cls.__subclasses__()
|
||||
except TypeError: # fails only when cls is type
|
||||
subs = cast('type[type]', cls).__subclasses__(cls)
|
||||
for sub in subs:
|
||||
yield sub
|
||||
yield from iter_subclasses(sub)
|
||||
85
lib/jaraco/classes/meta.py
Normal file
85
lib/jaraco/classes/meta.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
meta.py
|
||||
|
||||
Some useful metaclasses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LeafClassesMeta(type):
|
||||
"""
|
||||
A metaclass for classes that keeps track of all of them that
|
||||
aren't base classes.
|
||||
|
||||
>>> Parent = LeafClassesMeta('MyParentClass', (), {})
|
||||
>>> Parent in Parent._leaf_classes
|
||||
True
|
||||
>>> Child = LeafClassesMeta('MyChildClass', (Parent,), {})
|
||||
>>> Child in Parent._leaf_classes
|
||||
True
|
||||
>>> Parent in Parent._leaf_classes
|
||||
False
|
||||
|
||||
>>> Other = LeafClassesMeta('OtherClass', (), {})
|
||||
>>> Parent in Other._leaf_classes
|
||||
False
|
||||
>>> len(Other._leaf_classes)
|
||||
1
|
||||
"""
|
||||
|
||||
_leaf_classes: set[type[Any]]
|
||||
|
||||
def __init__(
|
||||
cls,
|
||||
name: str,
|
||||
bases: tuple[type[object], ...],
|
||||
attrs: dict[str, object],
|
||||
) -> None:
|
||||
if not hasattr(cls, '_leaf_classes'):
|
||||
cls._leaf_classes = set()
|
||||
leaf_classes = getattr(cls, '_leaf_classes')
|
||||
leaf_classes.add(cls)
|
||||
# remove any base classes
|
||||
leaf_classes -= set(bases)
|
||||
|
||||
|
||||
class TagRegistered(type):
|
||||
"""
|
||||
As classes of this metaclass are created, they keep a registry in the
|
||||
base class of all classes by a class attribute, indicated by attr_name.
|
||||
|
||||
>>> FooObject = TagRegistered('FooObject', (), dict(tag='foo'))
|
||||
>>> FooObject._registry['foo'] is FooObject
|
||||
True
|
||||
>>> BarObject = TagRegistered('Barobject', (FooObject,), dict(tag='bar'))
|
||||
>>> FooObject._registry is BarObject._registry
|
||||
True
|
||||
>>> len(FooObject._registry)
|
||||
2
|
||||
|
||||
'...' below should be 'jaraco.classes' but for pytest-dev/pytest#3396
|
||||
>>> FooObject._registry['bar']
|
||||
<class '....meta.Barobject'>
|
||||
"""
|
||||
|
||||
attr_name = 'tag'
|
||||
|
||||
def __init__(
|
||||
cls,
|
||||
name: str,
|
||||
bases: tuple[type[object], ...],
|
||||
namespace: dict[str, object],
|
||||
) -> None:
|
||||
super(TagRegistered, cls).__init__(name, bases, namespace)
|
||||
if not hasattr(cls, '_registry'):
|
||||
cls._registry = {}
|
||||
meta = cls.__class__
|
||||
attr = getattr(cls, meta.attr_name, None)
|
||||
if attr:
|
||||
cls._registry[attr] = cls
|
||||
241
lib/jaraco/classes/properties.py
Normal file
241
lib/jaraco/classes/properties.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar, cast, overload
|
||||
|
||||
_T = TypeVar('_T')
|
||||
_U = TypeVar('_U')
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from typing_extensions import Self, TypeAlias
|
||||
|
||||
# TODO(coherent-oss/granary#4): Migrate to PEP 695 by 2027-10.
|
||||
_GetterCallable: TypeAlias = Callable[..., _T]
|
||||
_GetterClassMethod: TypeAlias = classmethod[Any, [], _T]
|
||||
|
||||
_SetterCallable: TypeAlias = Callable[[type[Any], _T], None]
|
||||
_SetterClassMethod: TypeAlias = classmethod[Any, [_T], None]
|
||||
|
||||
class _ClassPropertyAttribute(Protocol[_T]):
|
||||
def __get__(self, obj: object, objtype: type[Any] | None = None) -> _T: ...
|
||||
|
||||
def __set__(self, obj: object, value: _T) -> None: ...
|
||||
|
||||
|
||||
class NonDataProperty(Generic[_T, _U]):
|
||||
"""Much like the property builtin, but only implements __get__,
|
||||
making it a non-data property, and can be subsequently reset.
|
||||
|
||||
See http://users.rcn.com/python/download/Descriptor.htm for more
|
||||
information.
|
||||
|
||||
>>> class X(object):
|
||||
... @NonDataProperty
|
||||
... def foo(self):
|
||||
... return 3
|
||||
>>> x = X()
|
||||
>>> x.foo
|
||||
3
|
||||
>>> x.foo = 4
|
||||
>>> x.foo
|
||||
4
|
||||
|
||||
'...' below should be 'jaraco.classes' but for pytest-dev/pytest#3396
|
||||
>>> X.foo
|
||||
<....properties.NonDataProperty object at ...>
|
||||
"""
|
||||
|
||||
def __init__(self, fget: Callable[[_T], _U]) -> None:
|
||||
assert fget is not None, "fget cannot be none"
|
||||
assert callable(fget), "fget must be callable"
|
||||
self.fget = fget
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self,
|
||||
obj: None,
|
||||
objtype: None,
|
||||
) -> Self: ...
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self,
|
||||
obj: _T,
|
||||
objtype: type[_T] | None = None,
|
||||
) -> _U: ...
|
||||
|
||||
def __get__(
|
||||
self,
|
||||
obj: _T | None,
|
||||
objtype: type[_T] | None = None,
|
||||
) -> Self | _U:
|
||||
if obj is None:
|
||||
return self
|
||||
return self.fget(obj)
|
||||
|
||||
|
||||
class classproperty(Generic[_T]):
|
||||
"""
|
||||
Like @property but applies at the class level.
|
||||
|
||||
|
||||
>>> class X(metaclass=classproperty.Meta):
|
||||
... val = None
|
||||
... @classproperty
|
||||
... def foo(cls):
|
||||
... return cls.val
|
||||
... @foo.setter
|
||||
... def foo(cls, val):
|
||||
... cls.val = val
|
||||
>>> X.foo
|
||||
>>> X.foo = 3
|
||||
>>> X.foo
|
||||
3
|
||||
>>> x = X()
|
||||
>>> x.foo
|
||||
3
|
||||
>>> X.foo = 4
|
||||
>>> x.foo
|
||||
4
|
||||
|
||||
Setting the property on an instance affects the class.
|
||||
|
||||
>>> x.foo = 5
|
||||
>>> x.foo
|
||||
5
|
||||
>>> X.foo
|
||||
5
|
||||
>>> vars(x)
|
||||
{}
|
||||
>>> X().foo
|
||||
5
|
||||
|
||||
Attempting to set an attribute where no setter was defined
|
||||
results in an AttributeError:
|
||||
|
||||
>>> class GetOnly(metaclass=classproperty.Meta):
|
||||
... @classproperty
|
||||
... def foo(cls):
|
||||
... return 'bar'
|
||||
>>> GetOnly.foo = 3
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: can't set attribute
|
||||
|
||||
It is also possible to wrap a classmethod or staticmethod in
|
||||
a classproperty.
|
||||
|
||||
>>> class Static(metaclass=classproperty.Meta):
|
||||
... @classproperty
|
||||
... @classmethod
|
||||
... def foo(cls):
|
||||
... return 'foo'
|
||||
... @classproperty
|
||||
... @staticmethod
|
||||
... def bar():
|
||||
... return 'bar'
|
||||
>>> Static.foo
|
||||
'foo'
|
||||
>>> Static.bar
|
||||
'bar'
|
||||
|
||||
*Legacy*
|
||||
|
||||
For compatibility, if the metaclass isn't specified, the
|
||||
legacy behavior will be invoked.
|
||||
|
||||
>>> class X:
|
||||
... val = None
|
||||
... @classproperty
|
||||
... def foo(cls):
|
||||
... return cls.val
|
||||
... @foo.setter
|
||||
... def foo(cls, val):
|
||||
... cls.val = val
|
||||
>>> X.foo
|
||||
>>> X.foo = 3
|
||||
>>> X.foo
|
||||
3
|
||||
>>> x = X()
|
||||
>>> x.foo
|
||||
3
|
||||
>>> X.foo = 4
|
||||
>>> x.foo
|
||||
4
|
||||
|
||||
Note, because the metaclass was not specified, setting
|
||||
a value on an instance does not have the intended effect.
|
||||
|
||||
>>> x.foo = 5
|
||||
>>> x.foo
|
||||
5
|
||||
>>> X.foo # should be 5
|
||||
4
|
||||
>>> vars(x) # should be empty
|
||||
{'foo': 5}
|
||||
>>> X().foo # should be 5
|
||||
4
|
||||
"""
|
||||
|
||||
fget: _ClassPropertyAttribute[_GetterClassMethod[_T]]
|
||||
fset: _ClassPropertyAttribute[_SetterClassMethod[_T] | None]
|
||||
|
||||
class Meta(type):
|
||||
def __setattr__(self, key: str, value: object) -> None:
|
||||
obj = self.__dict__.get(key, None)
|
||||
if type(obj) is classproperty:
|
||||
return obj.__set__(self, value)
|
||||
return super().__setattr__(key, value)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fget: _GetterCallable[_T] | _GetterClassMethod[_T],
|
||||
fset: _SetterCallable[_T] | _SetterClassMethod[_T] | None = None,
|
||||
) -> None:
|
||||
self.fget = self._ensure_method(fget)
|
||||
self.fset = fset # type: ignore[assignment] # Corrected in the next line.
|
||||
fset and self.setter(fset)
|
||||
|
||||
def __get__(self, instance: object, owner: type[object] | None = None) -> _T:
|
||||
return self.fget.__get__(None, owner)()
|
||||
|
||||
def __set__(self, owner: object, value: _T) -> None:
|
||||
if not self.fset:
|
||||
raise AttributeError("can't set attribute")
|
||||
if type(owner) is not classproperty.Meta:
|
||||
owner = type(owner)
|
||||
return self.fset.__get__(None, cast('type[object]', owner))(value)
|
||||
|
||||
def setter(self, fset: _SetterCallable[_T] | _SetterClassMethod[_T]) -> Self:
|
||||
self.fset = self._ensure_method(fset)
|
||||
return self
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def _ensure_method(
|
||||
cls,
|
||||
fn: _GetterCallable[_T] | _GetterClassMethod[_T],
|
||||
) -> _GetterClassMethod[_T]: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def _ensure_method(
|
||||
cls,
|
||||
fn: _SetterCallable[_T] | _SetterClassMethod[_T],
|
||||
) -> _SetterClassMethod[_T]: ...
|
||||
|
||||
@classmethod
|
||||
def _ensure_method(
|
||||
cls,
|
||||
fn: _GetterCallable[_T]
|
||||
| _GetterClassMethod[_T]
|
||||
| _SetterCallable[_T]
|
||||
| _SetterClassMethod[_T],
|
||||
) -> _GetterClassMethod[_T] | _SetterClassMethod[_T]:
|
||||
"""
|
||||
Ensure fn is a classmethod or staticmethod.
|
||||
"""
|
||||
needs_method = not isinstance(fn, (classmethod, staticmethod))
|
||||
return classmethod(fn) if needs_method else fn # type: ignore[arg-type,return-value]
|
||||
0
lib/jaraco/classes/py.typed
Normal file
0
lib/jaraco/classes/py.typed
Normal file
367
lib/jaraco/context/__init__.py
Normal file
367
lib/jaraco/context/__init__.py
Normal file
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import errno
|
||||
import functools
|
||||
import operator
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from collections.abc import Iterator
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
from backports import tarfile
|
||||
else:
|
||||
import tarfile
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def pushd(dir: str | os.PathLike) -> Iterator[str | os.PathLike]:
|
||||
"""
|
||||
>>> tmp_path = getfixture('tmp_path')
|
||||
>>> with pushd(tmp_path):
|
||||
... assert os.getcwd() == os.fspath(tmp_path)
|
||||
>>> assert os.getcwd() != os.fspath(tmp_path)
|
||||
"""
|
||||
|
||||
orig = os.getcwd()
|
||||
os.chdir(dir)
|
||||
try:
|
||||
yield dir
|
||||
finally:
|
||||
os.chdir(orig)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def tarball(
|
||||
url, target_dir: str | os.PathLike | None = None
|
||||
) -> Iterator[str | os.PathLike]:
|
||||
"""
|
||||
Get a URL to a tarball, download, extract, yield, then clean up.
|
||||
|
||||
Assumes everything in the tarball is prefixed with a common
|
||||
directory. That common path is stripped and the contents
|
||||
are extracted to ``target_dir``, similar to passing
|
||||
``-C {target} --strip-components 1`` to the ``tar`` command.
|
||||
|
||||
Uses the streaming protocol to extract the contents from a
|
||||
stream in a single pass without loading the whole file into
|
||||
memory.
|
||||
|
||||
>>> import urllib.request
|
||||
>>> url = getfixture('tarfile_served')
|
||||
>>> target = getfixture('tmp_path') / 'out'
|
||||
>>> tb = tarball(url, target_dir=target)
|
||||
>>> import pathlib
|
||||
>>> with tb as extracted:
|
||||
... contents = pathlib.Path(extracted, 'contents.txt').read_text(encoding='utf-8')
|
||||
>>> assert not os.path.exists(extracted)
|
||||
|
||||
If the target is not specified, contents are extracted to a
|
||||
directory relative to the current working directory named after
|
||||
the name of the file as extracted from the URL.
|
||||
|
||||
>>> target = getfixture('tmp_path')
|
||||
>>> with pushd(target), tarball(url):
|
||||
... target.joinpath('served').is_dir()
|
||||
True
|
||||
"""
|
||||
if target_dir is None:
|
||||
target_dir = os.path.basename(url).replace('.tar.gz', '').replace('.tgz', '')
|
||||
os.mkdir(target_dir)
|
||||
try:
|
||||
req = urllib.request.urlopen(url)
|
||||
with tarfile.open(fileobj=req, mode='r|*') as tf:
|
||||
tf.extractall(path=target_dir, filter=_default_filter)
|
||||
yield target_dir
|
||||
finally:
|
||||
shutil.rmtree(target_dir)
|
||||
|
||||
|
||||
def _compose_tarfile_filters(*filters):
|
||||
def compose_two(f1, f2):
|
||||
return lambda member, path: f1(f2(member, path), path)
|
||||
|
||||
return functools.reduce(compose_two, filters, lambda member, path: member)
|
||||
|
||||
|
||||
def strip_first_component(
|
||||
member: tarfile.TarInfo,
|
||||
path,
|
||||
) -> tarfile.TarInfo:
|
||||
_, member.name = member.name.split('/', 1)
|
||||
return member
|
||||
|
||||
|
||||
_default_filter = _compose_tarfile_filters(tarfile.data_filter, strip_first_component)
|
||||
|
||||
|
||||
def _compose(*cmgrs):
|
||||
"""
|
||||
Compose any number of dependent context managers into a single one.
|
||||
|
||||
The last, innermost context manager may take arbitrary arguments, but
|
||||
each successive context manager should accept the result from the
|
||||
previous as a single parameter.
|
||||
|
||||
Like :func:`jaraco.functools.compose`, behavior works from right to
|
||||
left, so the context manager should be indicated from outermost to
|
||||
innermost.
|
||||
|
||||
Example, to create a context manager to change to a temporary
|
||||
directory:
|
||||
|
||||
>>> temp_dir_as_cwd = _compose(pushd, temp_dir)
|
||||
>>> with temp_dir_as_cwd() as dir:
|
||||
... assert os.path.samefile(os.getcwd(), dir)
|
||||
"""
|
||||
|
||||
def compose_two(inner, outer):
|
||||
def composed(*args, **kwargs):
|
||||
with inner(*args, **kwargs) as saved, outer(saved) as res:
|
||||
yield res
|
||||
|
||||
return contextlib.contextmanager(composed)
|
||||
|
||||
return functools.reduce(compose_two, reversed(cmgrs))
|
||||
|
||||
|
||||
tarball_cwd = _compose(pushd, tarball)
|
||||
"""
|
||||
A tarball context with the current working directory pointing to the contents.
|
||||
"""
|
||||
|
||||
|
||||
def remove_readonly(func, path, exc_info):
|
||||
"""
|
||||
Add support for removing read-only files on Windows.
|
||||
"""
|
||||
_, exc, _ = exc_info
|
||||
if func in (os.rmdir, os.remove, os.unlink) and exc.errno == errno.EACCES:
|
||||
# change the file to be readable,writable,executable: 0777
|
||||
os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
|
||||
# retry
|
||||
func(path)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def robust_remover():
|
||||
return (
|
||||
functools.partial(shutil.rmtree, onerror=remove_readonly)
|
||||
if platform.system() == 'Windows'
|
||||
else shutil.rmtree
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def temp_dir(remover=shutil.rmtree):
|
||||
"""
|
||||
Create a temporary directory context. Pass a custom remover
|
||||
to override the removal behavior.
|
||||
|
||||
>>> import pathlib
|
||||
>>> with temp_dir() as the_dir:
|
||||
... assert os.path.isdir(the_dir)
|
||||
>>> assert not os.path.exists(the_dir)
|
||||
"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
yield temp_dir
|
||||
finally:
|
||||
remover(temp_dir)
|
||||
|
||||
|
||||
robust_temp_dir = functools.partial(temp_dir, remover=robust_remover())
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def repo_context(
|
||||
url, branch: str | None = None, quiet: bool = True, dest_ctx=robust_temp_dir
|
||||
):
|
||||
"""
|
||||
Check out the repo indicated by url.
|
||||
|
||||
If dest_ctx is supplied, it should be a context manager
|
||||
to yield the target directory for the check out.
|
||||
|
||||
>>> getfixture('ensure_git')
|
||||
>>> getfixture('needs_internet')
|
||||
>>> repo = repo_context('https://github.com/jaraco/jaraco.context')
|
||||
>>> with repo as dest:
|
||||
... listing = os.listdir(dest)
|
||||
>>> 'README.rst' in listing
|
||||
True
|
||||
"""
|
||||
exe = 'git' if 'git' in url else 'hg'
|
||||
with dest_ctx() as repo_dir:
|
||||
cmd = [exe, 'clone', url, repo_dir]
|
||||
cmd.extend(['--branch', branch] * bool(branch))
|
||||
stream = subprocess.DEVNULL if quiet else None
|
||||
subprocess.check_call(cmd, stdout=stream, stderr=stream)
|
||||
yield repo_dir
|
||||
|
||||
|
||||
class ExceptionTrap:
|
||||
"""
|
||||
A context manager that will catch certain exceptions and provide an
|
||||
indication they occurred.
|
||||
|
||||
>>> with ExceptionTrap() as trap:
|
||||
... raise Exception()
|
||||
>>> bool(trap)
|
||||
True
|
||||
|
||||
>>> with ExceptionTrap() as trap:
|
||||
... pass
|
||||
>>> bool(trap)
|
||||
False
|
||||
|
||||
>>> with ExceptionTrap(ValueError) as trap:
|
||||
... raise ValueError("1 + 1 is not 3")
|
||||
>>> bool(trap)
|
||||
True
|
||||
>>> trap.value
|
||||
ValueError('1 + 1 is not 3')
|
||||
>>> trap.tb
|
||||
<traceback object at ...>
|
||||
|
||||
>>> with ExceptionTrap(ValueError) as trap:
|
||||
... raise Exception()
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
Exception
|
||||
|
||||
>>> bool(trap)
|
||||
False
|
||||
"""
|
||||
|
||||
exc_info = None, None, None
|
||||
|
||||
def __init__(self, exceptions=(Exception,)):
|
||||
self.exceptions = exceptions
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return self.exc_info[0]
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self.exc_info[1]
|
||||
|
||||
@property
|
||||
def tb(self):
|
||||
return self.exc_info[2]
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
type = exc_info[0]
|
||||
matches = type and issubclass(type, self.exceptions)
|
||||
if matches:
|
||||
self.exc_info = exc_info
|
||||
return matches
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.type)
|
||||
|
||||
def raises(self, func, *, _test=bool):
|
||||
"""
|
||||
Wrap func and replace the result with the truth
|
||||
value of the trap (True if an exception occurred).
|
||||
|
||||
First, give the decorator an alias to support Python 3.8
|
||||
Syntax.
|
||||
|
||||
>>> raises = ExceptionTrap(ValueError).raises
|
||||
|
||||
Now decorate a function that always fails.
|
||||
|
||||
>>> @raises
|
||||
... def fail():
|
||||
... raise ValueError('failed')
|
||||
>>> fail()
|
||||
True
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
with ExceptionTrap(self.exceptions) as trap:
|
||||
func(*args, **kwargs)
|
||||
return _test(trap)
|
||||
|
||||
return wrapper
|
||||
|
||||
def passes(self, func):
|
||||
"""
|
||||
Wrap func and replace the result with the truth
|
||||
value of the trap (True if no exception).
|
||||
|
||||
First, give the decorator an alias to support Python 3.8
|
||||
Syntax.
|
||||
|
||||
>>> passes = ExceptionTrap(ValueError).passes
|
||||
|
||||
Now decorate a function that always fails.
|
||||
|
||||
>>> @passes
|
||||
... def fail():
|
||||
... raise ValueError('failed')
|
||||
|
||||
>>> fail()
|
||||
False
|
||||
"""
|
||||
return self.raises(func, _test=operator.not_)
|
||||
|
||||
|
||||
class suppress(contextlib.suppress, contextlib.ContextDecorator):
|
||||
"""
|
||||
A version of contextlib.suppress with decorator support.
|
||||
|
||||
>>> @suppress(KeyError)
|
||||
... def key_error():
|
||||
... {}['']
|
||||
>>> key_error()
|
||||
"""
|
||||
|
||||
|
||||
class on_interrupt(contextlib.ContextDecorator):
|
||||
"""
|
||||
Replace a KeyboardInterrupt with SystemExit(1).
|
||||
|
||||
Useful in conjunction with console entry point functions.
|
||||
|
||||
>>> def do_interrupt():
|
||||
... raise KeyboardInterrupt()
|
||||
>>> on_interrupt('error')(do_interrupt)()
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
SystemExit: 1
|
||||
>>> on_interrupt('error', code=255)(do_interrupt)()
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
SystemExit: 255
|
||||
>>> on_interrupt('suppress')(do_interrupt)()
|
||||
>>> with __import__('pytest').raises(KeyboardInterrupt):
|
||||
... on_interrupt('ignore')(do_interrupt)()
|
||||
"""
|
||||
|
||||
def __init__(self, action='error', /, code=1):
|
||||
self.action = action
|
||||
self.code = code
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exctype, excinst, exctb):
|
||||
if exctype is not KeyboardInterrupt or self.action == 'ignore':
|
||||
return
|
||||
elif self.action == 'error':
|
||||
raise SystemExit(self.code) from excinst
|
||||
return self.action == 'suppress'
|
||||
BIN
lib/jaraco/context/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lib/jaraco/context/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
0
lib/jaraco/context/py.typed
Normal file
0
lib/jaraco/context/py.typed
Normal file
722
lib/jaraco/functools/__init__.py
Normal file
722
lib/jaraco/functools/__init__.py
Normal file
@@ -0,0 +1,722 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections.abc
|
||||
import functools
|
||||
import inspect
|
||||
import itertools
|
||||
import operator
|
||||
import time
|
||||
import types
|
||||
import warnings
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
import more_itertools
|
||||
|
||||
|
||||
def compose(*funcs):
|
||||
"""
|
||||
Compose any number of unary functions into a single unary function.
|
||||
|
||||
Comparable to
|
||||
`function composition <https://en.wikipedia.org/wiki/Function_composition>`_
|
||||
in mathematics:
|
||||
|
||||
``h = g ∘ f`` implies ``h(x) = g(f(x))``.
|
||||
|
||||
In Python, ``h = compose(g, f)``.
|
||||
|
||||
>>> import textwrap
|
||||
>>> expected = str.strip(textwrap.dedent(compose.__doc__))
|
||||
>>> strip_and_dedent = compose(str.strip, textwrap.dedent)
|
||||
>>> strip_and_dedent(compose.__doc__) == expected
|
||||
True
|
||||
|
||||
Compose also allows the innermost function to take arbitrary arguments.
|
||||
|
||||
>>> round_three = lambda x: round(x, ndigits=3)
|
||||
>>> f = compose(round_three, int.__truediv__)
|
||||
>>> [f(3*x, x+1) for x in range(1,10)]
|
||||
[1.5, 2.0, 2.25, 2.4, 2.5, 2.571, 2.625, 2.667, 2.7]
|
||||
"""
|
||||
|
||||
def compose_two(f1, f2):
|
||||
return lambda *args, **kwargs: f1(f2(*args, **kwargs))
|
||||
|
||||
return functools.reduce(compose_two, funcs)
|
||||
|
||||
|
||||
def once(func):
|
||||
"""
|
||||
Decorate func so it's only ever called the first time.
|
||||
|
||||
This decorator can ensure that an expensive or non-idempotent function
|
||||
will not be expensive on subsequent calls and is idempotent.
|
||||
|
||||
>>> add_three = once(lambda a: a+3)
|
||||
>>> add_three(3)
|
||||
6
|
||||
>>> add_three(9)
|
||||
6
|
||||
>>> add_three('12')
|
||||
6
|
||||
|
||||
To reset the stored value, simply clear the property ``saved_result``.
|
||||
|
||||
>>> del add_three.saved_result
|
||||
>>> add_three(9)
|
||||
12
|
||||
>>> add_three(8)
|
||||
12
|
||||
|
||||
Or invoke 'reset()' on it.
|
||||
|
||||
>>> add_three.reset()
|
||||
>>> add_three(-3)
|
||||
0
|
||||
>>> add_three(0)
|
||||
0
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not hasattr(wrapper, 'saved_result'):
|
||||
wrapper.saved_result = func(*args, **kwargs)
|
||||
return wrapper.saved_result
|
||||
|
||||
wrapper.reset = lambda: vars(wrapper).__delitem__('saved_result')
|
||||
return wrapper
|
||||
|
||||
|
||||
def method_cache(method, cache_wrapper=functools.lru_cache()):
|
||||
"""
|
||||
Wrap lru_cache to support storing the cache data in the object instances.
|
||||
|
||||
Abstracts the common paradigm where the method explicitly saves an
|
||||
underscore-prefixed protected property on first call and returns that
|
||||
subsequently.
|
||||
|
||||
>>> class MyClass:
|
||||
... calls = 0
|
||||
...
|
||||
... @method_cache
|
||||
... def method(self, value):
|
||||
... self.calls += 1
|
||||
... return value
|
||||
|
||||
>>> a = MyClass()
|
||||
>>> a.method(3)
|
||||
3
|
||||
>>> for x in range(75):
|
||||
... res = a.method(x)
|
||||
>>> a.calls
|
||||
75
|
||||
|
||||
Note that the apparent behavior will be exactly like that of lru_cache
|
||||
except that the cache is stored on each instance, so values in one
|
||||
instance will not flush values from another, and when an instance is
|
||||
deleted, so are the cached values for that instance.
|
||||
|
||||
>>> b = MyClass()
|
||||
>>> for x in range(35):
|
||||
... res = b.method(x)
|
||||
>>> b.calls
|
||||
35
|
||||
>>> a.method(0)
|
||||
0
|
||||
>>> a.calls
|
||||
75
|
||||
|
||||
Note that if method had been decorated with ``functools.lru_cache()``,
|
||||
a.calls would have been 76 (due to the cached value of 0 having been
|
||||
flushed by the 'b' instance).
|
||||
|
||||
Clear the cache with ``.cache_clear()``
|
||||
|
||||
>>> a.method.cache_clear()
|
||||
|
||||
Same for a method that hasn't yet been called.
|
||||
|
||||
>>> c = MyClass()
|
||||
>>> c.method.cache_clear()
|
||||
|
||||
Another cache wrapper may be supplied:
|
||||
|
||||
>>> cache = functools.lru_cache(maxsize=2)
|
||||
>>> MyClass.method2 = method_cache(lambda self: 3, cache_wrapper=cache)
|
||||
>>> a = MyClass()
|
||||
>>> a.method2()
|
||||
3
|
||||
|
||||
Caution - do not subsequently wrap the method with another decorator, such
|
||||
as ``@property``, which changes the semantics of the function.
|
||||
|
||||
See also
|
||||
http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/
|
||||
for another implementation and additional justification.
|
||||
"""
|
||||
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# it's the first call, replace the method with a cached, bound method
|
||||
bound_method = types.MethodType(method, self)
|
||||
cached_method = cache_wrapper(bound_method)
|
||||
setattr(self, method.__name__, cached_method)
|
||||
return cached_method(*args, **kwargs)
|
||||
|
||||
# Support cache clear even before cache has been created.
|
||||
wrapper.cache_clear = lambda: None
|
||||
|
||||
return _special_method_cache(method, cache_wrapper) or wrapper
|
||||
|
||||
|
||||
def _special_method_cache(method, cache_wrapper):
|
||||
"""
|
||||
Because Python treats special methods differently, it's not
|
||||
possible to use instance attributes to implement the cached
|
||||
methods.
|
||||
|
||||
Instead, install the wrapper method under a different name
|
||||
and return a simple proxy to that wrapper.
|
||||
|
||||
https://github.com/jaraco/jaraco.functools/issues/5
|
||||
"""
|
||||
name = method.__name__
|
||||
special_names = '__getattr__', '__getitem__'
|
||||
|
||||
if name not in special_names:
|
||||
return None
|
||||
|
||||
wrapper_name = '__cached' + name
|
||||
|
||||
def proxy(self, /, *args, **kwargs):
|
||||
if wrapper_name not in vars(self):
|
||||
bound = types.MethodType(method, self)
|
||||
cache = cache_wrapper(bound)
|
||||
setattr(self, wrapper_name, cache)
|
||||
else:
|
||||
cache = getattr(self, wrapper_name)
|
||||
return cache(*args, **kwargs)
|
||||
|
||||
return proxy
|
||||
|
||||
|
||||
def apply(transform):
|
||||
"""
|
||||
Decorate a function with a transform function that is
|
||||
invoked on results returned from the decorated function.
|
||||
|
||||
>>> @apply(reversed)
|
||||
... def get_numbers(start):
|
||||
... "doc for get_numbers"
|
||||
... return range(start, start+3)
|
||||
>>> list(get_numbers(4))
|
||||
[6, 5, 4]
|
||||
>>> get_numbers.__doc__
|
||||
'doc for get_numbers'
|
||||
"""
|
||||
|
||||
def wrap(func):
|
||||
return functools.wraps(func)(compose(transform, func))
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def result_invoke(action):
|
||||
r"""
|
||||
Decorate a function with an action function that is
|
||||
invoked on the results returned from the decorated
|
||||
function (for its side effect), then return the original
|
||||
result.
|
||||
|
||||
>>> @result_invoke(print)
|
||||
... def add_two(a, b):
|
||||
... return a + b
|
||||
>>> x = add_two(2, 3)
|
||||
5
|
||||
>>> x
|
||||
5
|
||||
"""
|
||||
|
||||
def wrap(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
action(result)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def invoke(f, /, *args, **kwargs):
|
||||
"""
|
||||
Call a function for its side effect after initialization.
|
||||
|
||||
The benefit of using the decorator instead of simply invoking a function
|
||||
after defining it is that it makes explicit the author's intent for the
|
||||
function to be called immediately. Whereas if one simply calls the
|
||||
function immediately, it's less obvious if that was intentional or
|
||||
incidental. It also avoids repeating the name - the two actions, defining
|
||||
the function and calling it immediately are modeled separately, but linked
|
||||
by the decorator construct.
|
||||
|
||||
The benefit of having a function construct (opposed to just invoking some
|
||||
behavior inline) is to serve as a scope in which the behavior occurs. It
|
||||
avoids polluting the global namespace with local variables, provides an
|
||||
anchor on which to attach documentation (docstring), keeps the behavior
|
||||
logically separated (instead of conceptually separated or not separated at
|
||||
all), and provides potential to re-use the behavior for testing or other
|
||||
purposes.
|
||||
|
||||
This function is named as a pithy way to communicate, "call this function
|
||||
primarily for its side effect", or "while defining this function, also
|
||||
take it aside and call it". It exists because there's no Python construct
|
||||
for "define and call" (nor should there be, as decorators serve this need
|
||||
just fine). The behavior happens immediately and synchronously.
|
||||
|
||||
>>> @invoke
|
||||
... def func(): print("called")
|
||||
called
|
||||
>>> func()
|
||||
called
|
||||
|
||||
Use functools.partial to pass parameters to the initial call
|
||||
|
||||
>>> @functools.partial(invoke, name='bingo')
|
||||
... def func(name): print('called with', name)
|
||||
called with bingo
|
||||
"""
|
||||
f(*args, **kwargs)
|
||||
return f
|
||||
|
||||
|
||||
_T = TypeVar('_T')
|
||||
|
||||
|
||||
def passthrough(func: Callable[..., object]) -> Callable[[_T], _T]:
|
||||
"""
|
||||
Wrap the function to always return the first parameter.
|
||||
|
||||
>>> passthrough(print)('3')
|
||||
3
|
||||
'3'
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(first: _T, *args, **kwargs) -> _T:
|
||||
func(first, *args, **kwargs)
|
||||
return first
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class Throttler:
|
||||
"""Rate-limit a function (or other callable)."""
|
||||
|
||||
def __init__(self, func, max_rate=float('Inf')):
|
||||
if isinstance(func, Throttler):
|
||||
func = func.func
|
||||
self.func = func
|
||||
self.max_rate = max_rate
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.last_called = 0
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self._wait()
|
||||
return self.func(*args, **kwargs)
|
||||
|
||||
def _wait(self):
|
||||
"""Ensure at least 1/max_rate seconds from last call."""
|
||||
elapsed = time.time() - self.last_called
|
||||
must_wait = 1 / self.max_rate - elapsed
|
||||
time.sleep(max(0, must_wait))
|
||||
self.last_called = time.time()
|
||||
|
||||
def __get__(self, obj, owner=None):
|
||||
return first_invoke(self._wait, functools.partial(self.func, obj))
|
||||
|
||||
|
||||
def first_invoke(func1, func2):
|
||||
"""
|
||||
Return a function that when invoked will invoke func1 without
|
||||
any parameters (for its side effect) and then invoke func2
|
||||
with whatever parameters were passed, returning its result.
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
func1()
|
||||
return func2(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
method_caller = first_invoke(
|
||||
lambda: warnings.warn(
|
||||
'`jaraco.functools.method_caller` is deprecated, '
|
||||
'use `operator.methodcaller` instead',
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
),
|
||||
operator.methodcaller,
|
||||
)
|
||||
|
||||
|
||||
def retry_call(func, cleanup=lambda: None, retries=0, trap=()):
|
||||
"""
|
||||
Given a callable func, trap the indicated exceptions
|
||||
for up to 'retries' times, invoking cleanup on the
|
||||
exception. On the final attempt, allow any exceptions
|
||||
to propagate.
|
||||
"""
|
||||
attempts = itertools.count() if retries == float('inf') else range(retries)
|
||||
for _ in attempts:
|
||||
try:
|
||||
return func()
|
||||
except trap:
|
||||
cleanup()
|
||||
|
||||
return func()
|
||||
|
||||
|
||||
def retry(*r_args, **r_kwargs):
|
||||
"""
|
||||
Decorator wrapper for retry_call. Accepts arguments to retry_call
|
||||
except func and then returns a decorator for the decorated function.
|
||||
|
||||
Ex:
|
||||
|
||||
>>> @retry(retries=3)
|
||||
... def my_func(a, b):
|
||||
... "this is my funk"
|
||||
... print(a, b)
|
||||
>>> my_func.__doc__
|
||||
'this is my funk'
|
||||
"""
|
||||
|
||||
def decorate(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*f_args, **f_kwargs):
|
||||
bound = functools.partial(func, *f_args, **f_kwargs)
|
||||
return retry_call(bound, *r_args, **r_kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def print_yielded(func):
|
||||
"""
|
||||
Convert a generator into a function that prints all yielded elements.
|
||||
|
||||
>>> @print_yielded
|
||||
... def x():
|
||||
... yield 3; yield None
|
||||
>>> x()
|
||||
3
|
||||
None
|
||||
"""
|
||||
print_all = functools.partial(map, print)
|
||||
print_results = compose(more_itertools.consume, print_all, func)
|
||||
return functools.wraps(func)(print_results)
|
||||
|
||||
|
||||
def pass_none(func):
|
||||
"""
|
||||
Wrap func so it's not called if its first param is None.
|
||||
|
||||
>>> print_text = pass_none(print)
|
||||
>>> print_text('text')
|
||||
text
|
||||
>>> print_text(None)
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(param, /, *args, **kwargs):
|
||||
if param is not None:
|
||||
return func(param, *args, **kwargs)
|
||||
return None
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def none_as(value, replacement=None):
|
||||
"""
|
||||
>>> none_as(None, 'foo')
|
||||
'foo'
|
||||
>>> none_as('bar', 'foo')
|
||||
'bar'
|
||||
"""
|
||||
return replacement if value is None else value
|
||||
|
||||
|
||||
def assign_params(func, namespace):
|
||||
"""
|
||||
Assign parameters from namespace where func solicits.
|
||||
|
||||
>>> def func(x, y=3):
|
||||
... print(x, y)
|
||||
>>> assigned = assign_params(func, dict(x=2, z=4))
|
||||
>>> assigned()
|
||||
2 3
|
||||
|
||||
The usual errors are raised if a function doesn't receive
|
||||
its required parameters:
|
||||
|
||||
>>> assigned = assign_params(func, dict(y=3, z=4))
|
||||
>>> assigned()
|
||||
Traceback (most recent call last):
|
||||
TypeError: func() ...argument...
|
||||
|
||||
It even works on methods:
|
||||
|
||||
>>> class Handler:
|
||||
... def meth(self, arg):
|
||||
... print(arg)
|
||||
>>> assign_params(Handler().meth, dict(arg='crystal', foo='clear'))()
|
||||
crystal
|
||||
"""
|
||||
sig = inspect.signature(func)
|
||||
params = sig.parameters.keys()
|
||||
call_ns = {k: namespace[k] for k in params if k in namespace}
|
||||
return functools.partial(func, **call_ns)
|
||||
|
||||
|
||||
def save_method_args(method):
|
||||
"""
|
||||
Wrap a method such that when it is called, the args and kwargs are
|
||||
saved on the method.
|
||||
|
||||
>>> class MyClass:
|
||||
... @save_method_args
|
||||
... def method(self, a, b):
|
||||
... print(a, b)
|
||||
>>> my_ob = MyClass()
|
||||
>>> my_ob.method(1, 2)
|
||||
1 2
|
||||
>>> my_ob._saved_method.args
|
||||
(1, 2)
|
||||
>>> my_ob._saved_method.kwargs
|
||||
{}
|
||||
>>> my_ob.method(a=3, b='foo')
|
||||
3 foo
|
||||
>>> my_ob._saved_method.args
|
||||
()
|
||||
>>> my_ob._saved_method.kwargs == dict(a=3, b='foo')
|
||||
True
|
||||
|
||||
The arguments are stored on the instance, allowing for
|
||||
different instance to save different args.
|
||||
|
||||
>>> your_ob = MyClass()
|
||||
>>> your_ob.method({str('x'): 3}, b=[4])
|
||||
{'x': 3} [4]
|
||||
>>> your_ob._saved_method.args
|
||||
({'x': 3},)
|
||||
>>> my_ob._saved_method.args
|
||||
()
|
||||
"""
|
||||
args_and_kwargs = collections.namedtuple('args_and_kwargs', 'args kwargs') # noqa: PYI024 # Internal; stubs used for typing
|
||||
|
||||
@functools.wraps(method)
|
||||
def wrapper(self, /, *args, **kwargs):
|
||||
attr_name = '_saved_' + method.__name__
|
||||
attr = args_and_kwargs(args, kwargs)
|
||||
setattr(self, attr_name, attr)
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def except_(*exceptions, replace=None, use=None):
|
||||
"""
|
||||
Replace the indicated exceptions, if raised, with the indicated
|
||||
literal replacement or evaluated expression (if present).
|
||||
|
||||
>>> safe_int = except_(ValueError)(int)
|
||||
>>> safe_int('five')
|
||||
>>> safe_int('5')
|
||||
5
|
||||
|
||||
Specify a literal replacement with ``replace``.
|
||||
|
||||
>>> safe_int_r = except_(ValueError, replace=0)(int)
|
||||
>>> safe_int_r('five')
|
||||
0
|
||||
|
||||
Provide an expression to ``use`` to pass through particular parameters.
|
||||
|
||||
>>> safe_int_pt = except_(ValueError, use='args[0]')(int)
|
||||
>>> safe_int_pt('five')
|
||||
'five'
|
||||
|
||||
"""
|
||||
|
||||
def decorate(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions:
|
||||
try:
|
||||
return eval(use)
|
||||
except TypeError:
|
||||
return replace
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def identity(x):
|
||||
"""
|
||||
Return the argument.
|
||||
|
||||
>>> o = object()
|
||||
>>> identity(o) is o
|
||||
True
|
||||
"""
|
||||
return x
|
||||
|
||||
|
||||
def bypass_when(check, *, _op=identity):
|
||||
"""
|
||||
Decorate a function to return its parameter when ``check``.
|
||||
|
||||
>>> bypassed = [] # False
|
||||
|
||||
>>> @bypass_when(bypassed)
|
||||
... def double(x):
|
||||
... return x * 2
|
||||
>>> double(2)
|
||||
4
|
||||
>>> bypassed[:] = [object()] # True
|
||||
>>> double(2)
|
||||
2
|
||||
"""
|
||||
|
||||
def decorate(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(param, /):
|
||||
return param if _op(check) else func(param)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def bypass_unless(check):
|
||||
"""
|
||||
Decorate a function to return its parameter unless ``check``.
|
||||
|
||||
>>> enabled = [object()] # True
|
||||
|
||||
>>> @bypass_unless(enabled)
|
||||
... def double(x):
|
||||
... return x * 2
|
||||
>>> double(2)
|
||||
4
|
||||
>>> del enabled[:] # False
|
||||
>>> double(2)
|
||||
2
|
||||
"""
|
||||
return bypass_when(check, _op=operator.not_)
|
||||
|
||||
|
||||
@functools.singledispatch
|
||||
def _splat_inner(args, func):
|
||||
"""Splat args to func."""
|
||||
return func(*args)
|
||||
|
||||
|
||||
@_splat_inner.register
|
||||
def _(args: collections.abc.Mapping, func):
|
||||
"""Splat kargs to func as kwargs."""
|
||||
return func(**args)
|
||||
|
||||
|
||||
def splat(func):
|
||||
"""
|
||||
Wrap func to expect its parameters to be passed positionally in a tuple.
|
||||
|
||||
Has a similar effect to that of ``itertools.starmap`` over
|
||||
simple ``map``.
|
||||
|
||||
>>> pairs = [(-1, 1), (0, 2)]
|
||||
>>> more_itertools.consume(itertools.starmap(print, pairs))
|
||||
-1 1
|
||||
0 2
|
||||
>>> more_itertools.consume(map(splat(print), pairs))
|
||||
-1 1
|
||||
0 2
|
||||
|
||||
The approach generalizes to other iterators that don't have a "star"
|
||||
equivalent, such as a "starfilter".
|
||||
|
||||
>>> list(filter(splat(operator.add), pairs))
|
||||
[(0, 2)]
|
||||
|
||||
Splat also accepts a mapping argument.
|
||||
|
||||
>>> def is_nice(msg, code):
|
||||
... return "smile" in msg or code == 0
|
||||
>>> msgs = [
|
||||
... dict(msg='smile!', code=20),
|
||||
... dict(msg='error :(', code=1),
|
||||
... dict(msg='unknown', code=0),
|
||||
... ]
|
||||
>>> for msg in filter(splat(is_nice), msgs):
|
||||
... print(msg)
|
||||
{'msg': 'smile!', 'code': 20}
|
||||
{'msg': 'unknown', 'code': 0}
|
||||
"""
|
||||
return functools.wraps(func)(functools.partial(_splat_inner, func=func))
|
||||
|
||||
|
||||
_T = TypeVar('_T')
|
||||
|
||||
|
||||
def chainable(method: Callable[[_T, ...], None]) -> Callable[[_T, ...], _T]:
|
||||
"""
|
||||
Wrap an instance method to always return self.
|
||||
|
||||
|
||||
>>> class Dingus:
|
||||
... @chainable
|
||||
... def set_attr(self, name, val):
|
||||
... setattr(self, name, val)
|
||||
>>> d = Dingus().set_attr('a', 'eh!')
|
||||
>>> d.a
|
||||
'eh!'
|
||||
>>> d2 = Dingus().set_attr('a', 'eh!').set_attr('b', 'bee!')
|
||||
>>> d2.a + d2.b
|
||||
'eh!bee!'
|
||||
|
||||
Enforces that the return value is null.
|
||||
|
||||
>>> class BorkedDingus:
|
||||
... @chainable
|
||||
... def set_attr(self, name, val):
|
||||
... setattr(self, name, val)
|
||||
... return len(name)
|
||||
>>> BorkedDingus().set_attr('a', 'eh!')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AssertionError
|
||||
"""
|
||||
|
||||
@functools.wraps(method)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
assert method(self, *args, **kwargs) is None
|
||||
return self
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
"""
|
||||
A no-operation function that does nothing.
|
||||
|
||||
>>> noop(1, 2, three=3)
|
||||
"""
|
||||
123
lib/jaraco/functools/__init__.pyi
Normal file
123
lib/jaraco/functools/__init__.pyi
Normal file
@@ -0,0 +1,123 @@
|
||||
from collections.abc import Callable, Hashable, Iterator
|
||||
from functools import partial
|
||||
from operator import methodcaller
|
||||
from typing import (
|
||||
Any,
|
||||
Generic,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
overload,
|
||||
)
|
||||
|
||||
from typing_extensions import Concatenate, ParamSpec, TypeVarTuple, Unpack
|
||||
|
||||
_P = ParamSpec('_P')
|
||||
_R = TypeVar('_R')
|
||||
_T = TypeVar('_T')
|
||||
_Ts = TypeVarTuple('_Ts')
|
||||
_R1 = TypeVar('_R1')
|
||||
_R2 = TypeVar('_R2')
|
||||
_V = TypeVar('_V')
|
||||
_S = TypeVar('_S')
|
||||
_R_co = TypeVar('_R_co', covariant=True)
|
||||
|
||||
class _OnceCallable(Protocol[_P, _R]):
|
||||
saved_result: _R
|
||||
reset: Callable[[], None]
|
||||
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R: ...
|
||||
|
||||
class _ProxyMethodCacheWrapper(Protocol[_R_co]):
|
||||
cache_clear: Callable[[], None]
|
||||
def __call__(self, *args: Hashable, **kwargs: Hashable) -> _R_co: ...
|
||||
|
||||
class _MethodCacheWrapper(Protocol[_R_co]):
|
||||
def cache_clear(self) -> None: ...
|
||||
def __call__(self, *args: Hashable, **kwargs: Hashable) -> _R_co: ...
|
||||
|
||||
# `compose()` overloads below will cover most use cases.
|
||||
|
||||
@overload
|
||||
def compose(
|
||||
__func1: Callable[[_R], _T],
|
||||
__func2: Callable[_P, _R],
|
||||
/,
|
||||
) -> Callable[_P, _T]: ...
|
||||
@overload
|
||||
def compose(
|
||||
__func1: Callable[[_R], _T],
|
||||
__func2: Callable[[_R1], _R],
|
||||
__func3: Callable[_P, _R1],
|
||||
/,
|
||||
) -> Callable[_P, _T]: ...
|
||||
@overload
|
||||
def compose(
|
||||
__func1: Callable[[_R], _T],
|
||||
__func2: Callable[[_R2], _R],
|
||||
__func3: Callable[[_R1], _R2],
|
||||
__func4: Callable[_P, _R1],
|
||||
/,
|
||||
) -> Callable[_P, _T]: ...
|
||||
def once(func: Callable[_P, _R]) -> _OnceCallable[_P, _R]: ...
|
||||
def method_cache(
|
||||
method: Callable[..., _R],
|
||||
cache_wrapper: Callable[[Callable[..., _R]], _MethodCacheWrapper[_R]] = ...,
|
||||
) -> _MethodCacheWrapper[_R] | _ProxyMethodCacheWrapper[_R]: ...
|
||||
def apply(
|
||||
transform: Callable[[_R], _T],
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _T]]: ...
|
||||
def result_invoke(
|
||||
action: Callable[[_R], Any],
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ...
|
||||
def invoke(
|
||||
f: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> Callable[_P, _R]: ...
|
||||
|
||||
class Throttler(Generic[_R]):
|
||||
last_called: float
|
||||
func: Callable[..., _R]
|
||||
max_rate: float
|
||||
def __init__(
|
||||
self, func: Callable[..., _R] | Throttler[_R], max_rate: float = ...
|
||||
) -> None: ...
|
||||
def reset(self) -> None: ...
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> _R: ...
|
||||
def __get__(self, obj: Any, owner: type[Any] | None = ...) -> Callable[..., _R]: ...
|
||||
|
||||
def first_invoke(
|
||||
func1: Callable[..., Any], func2: Callable[_P, _R]
|
||||
) -> Callable[_P, _R]: ...
|
||||
|
||||
method_caller: Callable[..., methodcaller]
|
||||
|
||||
def retry_call(
|
||||
func: Callable[..., _R],
|
||||
cleanup: Callable[..., None] = ...,
|
||||
retries: float = ...,
|
||||
trap: type[BaseException] | tuple[type[BaseException], ...] = ...,
|
||||
) -> _R: ...
|
||||
def retry(
|
||||
cleanup: Callable[..., None] = ...,
|
||||
retries: float = ...,
|
||||
trap: type[BaseException] | tuple[type[BaseException], ...] = ...,
|
||||
) -> Callable[[Callable[..., _R]], Callable[..., _R]]: ...
|
||||
def print_yielded(func: Callable[_P, Iterator[Any]]) -> Callable[_P, None]: ...
|
||||
def pass_none(
|
||||
func: Callable[Concatenate[_T, _P], _R],
|
||||
) -> Callable[Concatenate[_T, _P], _R]: ...
|
||||
def assign_params(
|
||||
func: Callable[..., _R], namespace: dict[str, Any]
|
||||
) -> partial[_R]: ...
|
||||
def save_method_args(
|
||||
method: Callable[Concatenate[_S, _P], _R],
|
||||
) -> Callable[Concatenate[_S, _P], _R]: ...
|
||||
def except_(
|
||||
*exceptions: type[BaseException], replace: Any = ..., use: Any = ...
|
||||
) -> Callable[[Callable[_P, Any]], Callable[_P, Any]]: ...
|
||||
def identity(x: _T) -> _T: ...
|
||||
def bypass_when(
|
||||
check: _V, *, _op: Callable[[_V], Any] = ...
|
||||
) -> Callable[[Callable[[_T], _R]], Callable[[_T], _T | _R]]: ...
|
||||
def bypass_unless(
|
||||
check: Any,
|
||||
) -> Callable[[Callable[[_T], _R]], Callable[[_T], _T | _R]]: ...
|
||||
def splat(func: Callable[[Unpack[_Ts]], _R]) -> Callable[[tuple[Unpack[_Ts]]], _R]: ...
|
||||
BIN
lib/jaraco/functools/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lib/jaraco/functools/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
0
lib/jaraco/functools/py.typed
Normal file
0
lib/jaraco/functools/py.typed
Normal file
1
lib/jaraco_context-6.1.0.dist-info/INSTALLER
Normal file
1
lib/jaraco_context-6.1.0.dist-info/INSTALLER
Normal file
@@ -0,0 +1 @@
|
||||
pip
|
||||
82
lib/jaraco_context-6.1.0.dist-info/METADATA
Normal file
82
lib/jaraco_context-6.1.0.dist-info/METADATA
Normal file
@@ -0,0 +1,82 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: jaraco.context
|
||||
Version: 6.1.0
|
||||
Summary: Useful decorators and context managers
|
||||
Author-email: "Jason R. Coombs" <jaraco@jaraco.com>
|
||||
License-Expression: MIT
|
||||
Project-URL: Source, https://github.com/jaraco/jaraco.context
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
Requires-Dist: backports.tarfile; python_version < "3.12"
|
||||
Provides-Extra: test
|
||||
Requires-Dist: pytest!=8.1.*,>=6; extra == "test"
|
||||
Requires-Dist: jaraco.test>=5.6.0; extra == "test"
|
||||
Requires-Dist: portend; extra == "test"
|
||||
Provides-Extra: doc
|
||||
Requires-Dist: sphinx>=3.5; extra == "doc"
|
||||
Requires-Dist: jaraco.packaging>=9.3; extra == "doc"
|
||||
Requires-Dist: rst.linker>=1.9; extra == "doc"
|
||||
Requires-Dist: furo; extra == "doc"
|
||||
Requires-Dist: sphinx-lint; extra == "doc"
|
||||
Requires-Dist: jaraco.tidelift>=1.4; extra == "doc"
|
||||
Provides-Extra: check
|
||||
Requires-Dist: pytest-checkdocs>=2.4; extra == "check"
|
||||
Requires-Dist: pytest-ruff>=0.2.1; sys_platform != "cygwin" and extra == "check"
|
||||
Provides-Extra: cover
|
||||
Requires-Dist: pytest-cov; extra == "cover"
|
||||
Provides-Extra: enabler
|
||||
Requires-Dist: pytest-enabler>=3.4; extra == "enabler"
|
||||
Provides-Extra: type
|
||||
Requires-Dist: pytest-mypy>=1.0.1; extra == "type"
|
||||
Requires-Dist: mypy<1.19; platform_python_implementation == "PyPy" and extra == "type"
|
||||
Dynamic: license-file
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/jaraco.context.svg
|
||||
:target: https://pypi.org/project/jaraco.context
|
||||
|
||||
.. image:: https://img.shields.io/pypi/pyversions/jaraco.context.svg
|
||||
|
||||
.. image:: https://github.com/jaraco/jaraco.context/actions/workflows/main.yml/badge.svg
|
||||
:target: https://github.com/jaraco/jaraco.context/actions?query=workflow%3A%22tests%22
|
||||
:alt: tests
|
||||
|
||||
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
|
||||
:target: https://github.com/astral-sh/ruff
|
||||
:alt: Ruff
|
||||
|
||||
.. image:: https://readthedocs.org/projects/jaracocontext/badge/?version=latest
|
||||
:target: https://jaracocontext.readthedocs.io/en/latest/?badge=latest
|
||||
|
||||
.. image:: https://img.shields.io/badge/skeleton-2025-informational
|
||||
:target: https://blog.jaraco.com/skeleton
|
||||
|
||||
.. image:: https://tidelift.com/badges/package/pypi/jaraco.context
|
||||
:target: https://tidelift.com/subscription/pkg/pypi-jaraco.context?utm_source=pypi-jaraco.context&utm_medium=readme
|
||||
|
||||
|
||||
Highlights
|
||||
==========
|
||||
|
||||
See the docs linked from the badge above for the full details, but here are some features that may be of interest.
|
||||
|
||||
- ``ExceptionTrap`` provides a general-purpose wrapper for trapping exceptions and then acting on the outcome. Includes ``passes`` and ``raises`` decorators to replace the result of a wrapped function by a boolean indicating the outcome of the exception trap. See `this keyring commit <https://github.com/jaraco/keyring/commit/a85a7cbc6c909f8121660ed1f7b487f99a1c2bf7>`_ for an example of it in production.
|
||||
- ``suppress`` simply enables ``contextlib.suppress`` as a decorator.
|
||||
- ``on_interrupt`` is a decorator used by CLI entry points to affect the handling of a ``KeyboardInterrupt``. Inspired by `Lucretiel/autocommand#18 <https://github.com/Lucretiel/autocommand/issues/18>`_.
|
||||
- ``pushd`` is similar to pytest's ``monkeypatch.chdir`` or path's `default context <https://path.readthedocs.io/en/latest/api.html>`_, changes the current working directory for the duration of the context.
|
||||
- ``tarball`` will download a tarball, extract it, change directory, yield, then clean up after. Convenient when working with web assets.
|
||||
- ``null`` is there for those times when one code branch needs a context and the other doesn't; this null context provides symmetry across those branches.
|
||||
|
||||
|
||||
For Enterprise
|
||||
==============
|
||||
|
||||
Available as part of the Tidelift Subscription.
|
||||
|
||||
This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use.
|
||||
|
||||
`Learn more <https://tidelift.com/subscription/pkg/pypi-jaraco.context?utm_source=pypi-jaraco.context&utm_medium=referral&utm_campaign=github>`_.
|
||||
9
lib/jaraco_context-6.1.0.dist-info/RECORD
Normal file
9
lib/jaraco_context-6.1.0.dist-info/RECORD
Normal file
@@ -0,0 +1,9 @@
|
||||
jaraco/context/__init__.py,sha256=br1ydYGo1Xr_Pu1anuEdd-QrjUiz_EY5L_5E4C03L4w,9809
|
||||
jaraco/context/__pycache__/__init__.cpython-314.pyc,,
|
||||
jaraco/context/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
jaraco_context-6.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
jaraco_context-6.1.0.dist-info/METADATA,sha256=BDXr_FIFXFqZdO0gwXG2RUOD6vnbsVCIFLp62XxZ1xI,4270
|
||||
jaraco_context-6.1.0.dist-info/RECORD,,
|
||||
jaraco_context-6.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
||||
jaraco_context-6.1.0.dist-info/licenses/LICENSE,sha256=l1WhhRlmbl8PTK49qtPXASvK5IpgCzEjfXXp_hNOZoM,1076
|
||||
jaraco_context-6.1.0.dist-info/top_level.txt,sha256=0JnN3LfXH4LIRfXL-QFOGCJzQWZO3ELx4R1d_louoQM,7
|
||||
5
lib/jaraco_context-6.1.0.dist-info/WHEEL
Normal file
5
lib/jaraco_context-6.1.0.dist-info/WHEEL
Normal file
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (80.9.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
18
lib/jaraco_context-6.1.0.dist-info/licenses/LICENSE
Normal file
18
lib/jaraco_context-6.1.0.dist-info/licenses/LICENSE
Normal file
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1
lib/jaraco_context-6.1.0.dist-info/top_level.txt
Normal file
1
lib/jaraco_context-6.1.0.dist-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
jaraco
|
||||
1
lib/jaraco_functools-4.4.0.dist-info/INSTALLER
Normal file
1
lib/jaraco_functools-4.4.0.dist-info/INSTALLER
Normal file
@@ -0,0 +1 @@
|
||||
pip
|
||||
69
lib/jaraco_functools-4.4.0.dist-info/METADATA
Normal file
69
lib/jaraco_functools-4.4.0.dist-info/METADATA
Normal file
@@ -0,0 +1,69 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: jaraco.functools
|
||||
Version: 4.4.0
|
||||
Summary: Functools like those found in stdlib
|
||||
Author-email: "Jason R. Coombs" <jaraco@jaraco.com>
|
||||
License-Expression: MIT
|
||||
Project-URL: Source, https://github.com/jaraco/jaraco.functools
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
Requires-Dist: more_itertools
|
||||
Provides-Extra: test
|
||||
Requires-Dist: pytest!=8.1.*,>=6; extra == "test"
|
||||
Requires-Dist: jaraco.classes; extra == "test"
|
||||
Provides-Extra: doc
|
||||
Requires-Dist: sphinx>=3.5; extra == "doc"
|
||||
Requires-Dist: jaraco.packaging>=9.3; extra == "doc"
|
||||
Requires-Dist: rst.linker>=1.9; extra == "doc"
|
||||
Requires-Dist: furo; extra == "doc"
|
||||
Requires-Dist: sphinx-lint; extra == "doc"
|
||||
Requires-Dist: jaraco.tidelift>=1.4; extra == "doc"
|
||||
Provides-Extra: check
|
||||
Requires-Dist: pytest-checkdocs>=2.4; extra == "check"
|
||||
Requires-Dist: pytest-ruff>=0.2.1; sys_platform != "cygwin" and extra == "check"
|
||||
Provides-Extra: cover
|
||||
Requires-Dist: pytest-cov; extra == "cover"
|
||||
Provides-Extra: enabler
|
||||
Requires-Dist: pytest-enabler>=3.4; extra == "enabler"
|
||||
Provides-Extra: type
|
||||
Requires-Dist: pytest-mypy>=1.0.1; extra == "type"
|
||||
Requires-Dist: mypy<1.19; platform_python_implementation == "PyPy" and extra == "type"
|
||||
Dynamic: license-file
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/jaraco.functools.svg
|
||||
:target: https://pypi.org/project/jaraco.functools
|
||||
|
||||
.. image:: https://img.shields.io/pypi/pyversions/jaraco.functools.svg
|
||||
|
||||
.. image:: https://github.com/jaraco/jaraco.functools/actions/workflows/main.yml/badge.svg
|
||||
:target: https://github.com/jaraco/jaraco.functools/actions?query=workflow%3A%22tests%22
|
||||
:alt: tests
|
||||
|
||||
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
|
||||
:target: https://github.com/astral-sh/ruff
|
||||
:alt: Ruff
|
||||
|
||||
.. image:: https://readthedocs.org/projects/jaracofunctools/badge/?version=latest
|
||||
:target: https://jaracofunctools.readthedocs.io/en/latest/?badge=latest
|
||||
|
||||
.. image:: https://img.shields.io/badge/skeleton-2025-informational
|
||||
:target: https://blog.jaraco.com/skeleton
|
||||
|
||||
.. image:: https://tidelift.com/badges/package/pypi/jaraco.functools
|
||||
:target: https://tidelift.com/subscription/pkg/pypi-jaraco.functools?utm_source=pypi-jaraco.functools&utm_medium=readme
|
||||
|
||||
Additional functools in the spirit of stdlib's functools.
|
||||
|
||||
For Enterprise
|
||||
==============
|
||||
|
||||
Available as part of the Tidelift Subscription.
|
||||
|
||||
This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use.
|
||||
|
||||
`Learn more <https://tidelift.com/subscription/pkg/pypi-jaraco.functools?utm_source=pypi-jaraco.functools&utm_medium=referral&utm_campaign=github>`_.
|
||||
10
lib/jaraco_functools-4.4.0.dist-info/RECORD
Normal file
10
lib/jaraco_functools-4.4.0.dist-info/RECORD
Normal file
@@ -0,0 +1,10 @@
|
||||
jaraco/functools/__init__.py,sha256=ZJx9cMs2Nvk2xGUl8OjVGkpjdOaNlSzJrN4dGglgX2g,18599
|
||||
jaraco/functools/__init__.pyi,sha256=K4DcbnYIHE5QlMxqf9-cVp-WhycrhuTao4J7O7TMq4Y,3907
|
||||
jaraco/functools/__pycache__/__init__.cpython-314.pyc,,
|
||||
jaraco/functools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
jaraco_functools-4.4.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
jaraco_functools-4.4.0.dist-info/METADATA,sha256=LnnajcNGmSSr46yLIqP-tWkqeb-fR7vIa2U11hhkGEk,2960
|
||||
jaraco_functools-4.4.0.dist-info/RECORD,,
|
||||
jaraco_functools-4.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
||||
jaraco_functools-4.4.0.dist-info/licenses/LICENSE,sha256=WlfLTbheKi3YjCkGKJCK3VfjRRRJ4KmnH9-zh3b9dZ0,1076
|
||||
jaraco_functools-4.4.0.dist-info/top_level.txt,sha256=0JnN3LfXH4LIRfXL-QFOGCJzQWZO3ELx4R1d_louoQM,7
|
||||
5
lib/jaraco_functools-4.4.0.dist-info/WHEEL
Normal file
5
lib/jaraco_functools-4.4.0.dist-info/WHEEL
Normal file
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (80.9.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
18
lib/jaraco_functools-4.4.0.dist-info/licenses/LICENSE
Normal file
18
lib/jaraco_functools-4.4.0.dist-info/licenses/LICENSE
Normal file
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1
lib/jaraco_functools-4.4.0.dist-info/top_level.txt
Normal file
1
lib/jaraco_functools-4.4.0.dist-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
jaraco
|
||||
1
lib/jeepney-0.9.0.dist-info/INSTALLER
Normal file
1
lib/jeepney-0.9.0.dist-info/INSTALLER
Normal file
@@ -0,0 +1 @@
|
||||
pip
|
||||
35
lib/jeepney-0.9.0.dist-info/METADATA
Normal file
35
lib/jeepney-0.9.0.dist-info/METADATA
Normal file
@@ -0,0 +1,35 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: jeepney
|
||||
Version: 0.9.0
|
||||
Summary: Low-level, pure Python DBus protocol wrapper.
|
||||
Author-email: Thomas Kluyver <thomas@kluyver.me.uk>
|
||||
Requires-Python: >=3.7
|
||||
Description-Content-Type: text/x-rst
|
||||
License-Expression: MIT
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Topic :: Desktop Environment
|
||||
License-File: LICENSE
|
||||
Requires-Dist: pytest ; extra == "test"
|
||||
Requires-Dist: pytest-trio ; extra == "test"
|
||||
Requires-Dist: pytest-asyncio >=0.17 ; extra == "test"
|
||||
Requires-Dist: testpath ; extra == "test"
|
||||
Requires-Dist: trio ; extra == "test"
|
||||
Requires-Dist: async-timeout ; extra == "test" and ( python_version < '3.11')
|
||||
Requires-Dist: trio ; extra == "trio"
|
||||
Project-URL: Documentation, https://jeepney.readthedocs.io/en/latest/
|
||||
Project-URL: Source, https://gitlab.com/takluyver/jeepney
|
||||
Provides-Extra: test
|
||||
Provides-Extra: trio
|
||||
|
||||
Jeepney is a pure Python implementation of D-Bus messaging. It has an `I/O-free
|
||||
<https://sans-io.readthedocs.io/>`__ core, and integration modules for different
|
||||
event loops.
|
||||
|
||||
D-Bus is an inter-process communication system, mainly used in Linux.
|
||||
|
||||
To install Jeepney::
|
||||
|
||||
pip install jeepney
|
||||
|
||||
`Jeepney docs on Readthedocs <https://jeepney.readthedocs.io/en/latest/>`__
|
||||
|
||||
64
lib/jeepney-0.9.0.dist-info/RECORD
Normal file
64
lib/jeepney-0.9.0.dist-info/RECORD
Normal file
@@ -0,0 +1,64 @@
|
||||
jeepney-0.9.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
jeepney-0.9.0.dist-info/METADATA,sha256=uObDU-mq7Q7QFEApVWQX_aI7ZPNE5xgzGQcFS6BbjGI,1230
|
||||
jeepney-0.9.0.dist-info/RECORD,,
|
||||
jeepney-0.9.0.dist-info/WHEEL,sha256=_2ozNFCLWc93bK4WKHCO-eDUENDlo-dgc9cU3qokYO4,82
|
||||
jeepney-0.9.0.dist-info/licenses/LICENSE,sha256=GyKwSbUmfW38I6Z79KhNjsBLn9-xpR02DkK0NCyLQVQ,1081
|
||||
jeepney/__init__.py,sha256=ULhIr444tY81PUkEHRWNlTXlqRbFmqMTbWBsktKgmoI,408
|
||||
jeepney/__pycache__/__init__.cpython-314.pyc,,
|
||||
jeepney/__pycache__/auth.cpython-314.pyc,,
|
||||
jeepney/__pycache__/bindgen.cpython-314.pyc,,
|
||||
jeepney/__pycache__/bus.cpython-314.pyc,,
|
||||
jeepney/__pycache__/bus_messages.cpython-314.pyc,,
|
||||
jeepney/__pycache__/fds.cpython-314.pyc,,
|
||||
jeepney/__pycache__/low_level.cpython-314.pyc,,
|
||||
jeepney/__pycache__/wrappers.cpython-314.pyc,,
|
||||
jeepney/auth.py,sha256=ZW0HMX6Vfwx28P-jNrzVVgEn1ipjO-KJrNJ2SG90V3U,5409
|
||||
jeepney/bindgen.py,sha256=yPDJFt_WjKoFUp08r-_upsqu0L8Rmv8gNKr-MA4T4bI,6085
|
||||
jeepney/bus.py,sha256=KUiSr3ECzdbe-S9tNKm6kvf3oZi4RYnJWkZUXK7tE2k,1817
|
||||
jeepney/bus_messages.py,sha256=uUCc_1Xllzth4F95aghpDLmlv5Gz0are2FpKg7D_gqc,8239
|
||||
jeepney/fds.py,sha256=ZYzN_c_7rkBT0wU7dYUmQRijpSzCv-DATCYEklpXxUU,5056
|
||||
jeepney/io/__init__.py,sha256=inJI_1U-ATymLcAVYs-LD2aUwgl-tihW8-oVFUxYRgA,33
|
||||
jeepney/io/__pycache__/__init__.cpython-314.pyc,,
|
||||
jeepney/io/__pycache__/asyncio.cpython-314.pyc,,
|
||||
jeepney/io/__pycache__/blocking.cpython-314.pyc,,
|
||||
jeepney/io/__pycache__/common.cpython-314.pyc,,
|
||||
jeepney/io/__pycache__/threading.cpython-314.pyc,,
|
||||
jeepney/io/__pycache__/trio.cpython-314.pyc,,
|
||||
jeepney/io/asyncio.py,sha256=qfWi_1pWCXSP1LNRafHBuvrxHx4tX96b52KBa4sUFMc,7622
|
||||
jeepney/io/blocking.py,sha256=I_rw90IY_EesBZmkfUqk7UniyVkQAngz7jyQmzju680,11940
|
||||
jeepney/io/common.py,sha256=l8lbFUgQmBxfqSC-hqHYmPUYCVFMKbOGB1k5ZWPKXfs,2696
|
||||
jeepney/io/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
jeepney/io/tests/__pycache__/__init__.cpython-314.pyc,,
|
||||
jeepney/io/tests/__pycache__/conftest.cpython-314.pyc,,
|
||||
jeepney/io/tests/__pycache__/test_asyncio.cpython-314.pyc,,
|
||||
jeepney/io/tests/__pycache__/test_blocking.cpython-314.pyc,,
|
||||
jeepney/io/tests/__pycache__/test_threading.cpython-314.pyc,,
|
||||
jeepney/io/tests/__pycache__/test_trio.cpython-314.pyc,,
|
||||
jeepney/io/tests/__pycache__/utils.cpython-314.pyc,,
|
||||
jeepney/io/tests/conftest.py,sha256=o7JrYypYE-0jNFUndsQ4Ek5dNYM0ofh1sYcIVeCZMj0,2730
|
||||
jeepney/io/tests/test_asyncio.py,sha256=JJtnX5HiRRZjjuGIDoI8LvzfbaSNg-ljiX95yUvd9xk,2720
|
||||
jeepney/io/tests/test_blocking.py,sha256=ETLnoivenN8Dzp0JB4wPOb9PNbpSuiocuP_IDeNRlI4,2804
|
||||
jeepney/io/tests/test_threading.py,sha256=RALwy-aI64TBoFmBnSU63HLcwRnStLVtnewOtoaBl3o,2699
|
||||
jeepney/io/tests/test_trio.py,sha256=DPY1V_K2qLTyBTrbrxZeLTA5dmca3Ye3e6pz08UxbO8,3892
|
||||
jeepney/io/tests/utils.py,sha256=i7VJYT-axefzS8mWcvv-9DeHEB6LdP9M82H3Hx6fyC4,79
|
||||
jeepney/io/threading.py,sha256=mwGCNlun_baX8Y4eienCGDKdZD4SKdTMvBTkIE0EMKo,9391
|
||||
jeepney/io/trio.py,sha256=IdZIJnQcPjVOBA9KooFn0nTBEz3BuBDkz56qLYhGR1M,15088
|
||||
jeepney/low_level.py,sha256=m4wGY-quPnzylgKlBdBccmkuOXF_hQ1gbtT25qPX2GM,19949
|
||||
jeepney/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
jeepney/tests/__pycache__/__init__.cpython-314.pyc,,
|
||||
jeepney/tests/__pycache__/test_auth.cpython-314.pyc,,
|
||||
jeepney/tests/__pycache__/test_bindgen.cpython-314.pyc,,
|
||||
jeepney/tests/__pycache__/test_bus.cpython-314.pyc,,
|
||||
jeepney/tests/__pycache__/test_bus_messages.cpython-314.pyc,,
|
||||
jeepney/tests/__pycache__/test_fds.cpython-314.pyc,,
|
||||
jeepney/tests/__pycache__/test_low_level.cpython-314.pyc,,
|
||||
jeepney/tests/__pycache__/test_wrappers.cpython-314.pyc,,
|
||||
jeepney/tests/secrets_introspect.xml,sha256=9cfNs1aGLtIAykcQVsycwIwCLmEeorKkFjqJCLAknRQ,4575
|
||||
jeepney/tests/test_auth.py,sha256=Ee79vsedCwveukudAZTwqYTXHWV3PYnXkmMl0MBMZEE,611
|
||||
jeepney/tests/test_bindgen.py,sha256=Ez99zr9TIV3mlZdH-2A_dz4LbvxCqzWDIadhOCbbaoc,1098
|
||||
jeepney/tests/test_bus.py,sha256=ApOxd3AcYQB14G1XsiFGBYtQ4xSKw52y9YvmPz700gc,847
|
||||
jeepney/tests/test_bus_messages.py,sha256=elwS7odY9RDsjg9jL4tN0O7uCxUqSYHsWShWXn_WPOQ,3338
|
||||
jeepney/tests/test_fds.py,sha256=-gyvQpfsXtPaIEeqbwhrNPOcIAN0DsrQ7MXZu4nMvvQ,1821
|
||||
jeepney/tests/test_low_level.py,sha256=2SC-wKKGr0yfEguswfHzCojSTwsYlTVLPyuzQbGS3L4,3000
|
||||
jeepney/tests/test_wrappers.py,sha256=NSY6LblWeU2kToISjpi9YHgrd_Y6PVyFwXqnbY93ygU,2202
|
||||
jeepney/wrappers.py,sha256=5zM_v1jFqEGDSaPh0f06SDxCF6JmWVhyXjfYR6KHum4,9605
|
||||
4
lib/jeepney-0.9.0.dist-info/WHEEL
Normal file
4
lib/jeepney-0.9.0.dist-info/WHEEL
Normal file
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: flit 3.11.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
21
lib/jeepney-0.9.0.dist-info/licenses/LICENSE
Normal file
21
lib/jeepney-0.9.0.dist-info/licenses/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017 Thomas Kluyver
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
13
lib/jeepney/__init__.py
Normal file
13
lib/jeepney/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Low-level, pure Python DBus protocol wrapper.
|
||||
"""
|
||||
from .auth import AuthenticationError, FDNegotiationError
|
||||
from .low_level import (
|
||||
Endianness, Header, HeaderFields, Message, MessageFlag, MessageType,
|
||||
Parser, SizeLimitError,
|
||||
)
|
||||
from .bus import find_session_bus, find_system_bus
|
||||
from .bus_messages import *
|
||||
from .fds import FileDescriptor, NoFDError
|
||||
from .wrappers import *
|
||||
|
||||
__version__ = '0.9.0'
|
||||
BIN
lib/jeepney/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/__pycache__/auth.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/__pycache__/bindgen.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/bindgen.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/__pycache__/bus.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/bus.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/__pycache__/bus_messages.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/bus_messages.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/__pycache__/fds.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/fds.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/__pycache__/low_level.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/low_level.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/__pycache__/wrappers.cpython-314.pyc
Normal file
BIN
lib/jeepney/__pycache__/wrappers.cpython-314.pyc
Normal file
Binary file not shown.
144
lib/jeepney/auth.py
Normal file
144
lib/jeepney/auth.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from binascii import hexlify
|
||||
from enum import Enum
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
def make_auth_external() -> bytes:
|
||||
"""Prepare an AUTH command line with the current effective user ID.
|
||||
|
||||
This is the preferred authentication method for typical D-Bus connections
|
||||
over a Unix domain socket.
|
||||
"""
|
||||
hex_uid = hexlify(str(os.geteuid()).encode('ascii'))
|
||||
return b'AUTH EXTERNAL %b\r\n' % hex_uid
|
||||
|
||||
def make_auth_anonymous() -> bytes:
|
||||
"""Format an AUTH command line for the ANONYMOUS mechanism
|
||||
|
||||
Jeepney's higher-level wrappers don't currently use this mechanism,
|
||||
but third-party code may choose to.
|
||||
|
||||
See <https://tools.ietf.org/html/rfc4505> for details.
|
||||
"""
|
||||
from . import __version__
|
||||
trace = hexlify(('Jeepney %s' % __version__).encode('ascii'))
|
||||
return b'AUTH ANONYMOUS %s\r\n' % trace
|
||||
|
||||
BEGIN = b'BEGIN\r\n'
|
||||
NEGOTIATE_UNIX_FD = b'NEGOTIATE_UNIX_FD\r\n'
|
||||
|
||||
class ClientState(Enum):
|
||||
# States from the D-Bus spec (plus 'Success'). Not all used in Jeepney.
|
||||
WaitingForData = 1
|
||||
WaitingForOk = 2
|
||||
WaitingForReject = 3
|
||||
WaitingForAgreeUnixFD = 4
|
||||
Success = 5
|
||||
|
||||
class AuthenticationError(ValueError):
|
||||
"""Raised when DBus authentication fails"""
|
||||
def __init__(self, data, msg="Authentication failed"):
|
||||
self.msg = msg
|
||||
self.data = data
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.msg}. Bus sent: {self.data!r}"
|
||||
|
||||
class FDNegotiationError(AuthenticationError):
|
||||
"""Raised when file descriptor support is requested but not available"""
|
||||
def __init__(self, data):
|
||||
super().__init__(data, msg="File descriptor support not available")
|
||||
|
||||
|
||||
class Authenticator:
|
||||
"""Process data for the SASL authentication conversation
|
||||
|
||||
If enable_fds is True, this includes negotiating support for passing
|
||||
file descriptors. If inc_null_byte is True, sends the '\0' byte
|
||||
at the beginning of the negotiations, which was the past behavior,
|
||||
but which prevents sending the SCM_CREDS ancillary data over the socket,
|
||||
breaking authentication on *BSD; the caller should rather send that
|
||||
null byte and ancillary data and pass inc_null_byte=False to prevent
|
||||
it being done here.
|
||||
"""
|
||||
def __init__(self, enable_fds=False, inc_null_byte=True):
|
||||
self.enable_fds = enable_fds
|
||||
self.buffer = bytearray()
|
||||
if inc_null_byte:
|
||||
self._to_send = b'\0' + make_auth_external()
|
||||
else:
|
||||
self._to_send = make_auth_external()
|
||||
self.state = ClientState.WaitingForOk
|
||||
self.error = None
|
||||
|
||||
@property
|
||||
def authenticated(self):
|
||||
return self.state is ClientState.Success
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.data_to_send, None)
|
||||
|
||||
def data_to_send(self) -> Optional[bytes]:
|
||||
"""Get a line of data to send to the server
|
||||
|
||||
The data returned should be sent before waiting to receive data.
|
||||
Returns empty bytes if waiting for more data from the server, and None
|
||||
if authentication is finished (success or error).
|
||||
|
||||
Iterating over the Authenticator object will also yield these lines;
|
||||
:meth:`feed` should be called with received data inside the loop.
|
||||
"""
|
||||
if self.authenticated or self.error:
|
||||
return None
|
||||
self._to_send, to_send = b'', self._to_send
|
||||
return to_send
|
||||
|
||||
def process_line(self, line):
|
||||
if self.state is ClientState.WaitingForOk:
|
||||
if line.startswith(b'OK '):
|
||||
if self.enable_fds:
|
||||
return NEGOTIATE_UNIX_FD, ClientState.WaitingForAgreeUnixFD
|
||||
else:
|
||||
return BEGIN, ClientState.Success
|
||||
# We only support EXTERNAL authentication, but if we allow others,
|
||||
# 'REJECTED <mechs>' would tell us to try another one.
|
||||
|
||||
elif self.state is ClientState.WaitingForAgreeUnixFD:
|
||||
if line.startswith(b'AGREE_UNIX_FD'):
|
||||
return BEGIN, ClientState.Success
|
||||
# The protocol allows us to continue if FD passing is rejected,
|
||||
# but Jeepney assumes that if you enable FD support you need it,
|
||||
# so we fail rather
|
||||
self.error = line
|
||||
raise FDNegotiationError(line)
|
||||
|
||||
self.error = line
|
||||
raise AuthenticationError(line)
|
||||
|
||||
def feed(self, data: bytes):
|
||||
"""Process received data
|
||||
|
||||
Raises AuthenticationError if the incoming data is not as expected for
|
||||
successful authentication. The connection should then be abandoned.
|
||||
"""
|
||||
self.buffer += data
|
||||
if b'\r\n' in self.buffer:
|
||||
line, self.buffer = self.buffer.split(b'\r\n', 1)
|
||||
if self.buffer:
|
||||
# We only expect one line before we reply
|
||||
raise AuthenticationError(self.buffer, "Unexpected data received")
|
||||
|
||||
self._to_send, self.state = self.process_line(line)
|
||||
|
||||
# Avoid consuming lots of memory if the server is not sending what we
|
||||
# expect. There doesn't appear to be a specified maximum line length,
|
||||
# but 8192 bytes leaves a sizeable margin over all the examples in the
|
||||
# spec (all < 100 bytes per line).
|
||||
elif len(self.buffer) > 8192:
|
||||
raise AuthenticationError(
|
||||
self.buffer, "Too much data received without line ending"
|
||||
)
|
||||
|
||||
|
||||
# Old name (behaviour on errors has changed, but should work for standard case)
|
||||
SASLParser = Authenticator
|
||||
170
lib/jeepney/bindgen.py
Normal file
170
lib/jeepney/bindgen.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Generate a wrapper class from DBus introspection data"""
|
||||
import argparse
|
||||
import os.path
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from textwrap import indent
|
||||
|
||||
from jeepney.wrappers import Introspectable
|
||||
from jeepney.io.blocking import open_dbus_connection, Proxy
|
||||
from jeepney import __version__
|
||||
|
||||
class Method:
|
||||
def __init__(self, xml_node):
|
||||
self.name = xml_node.attrib['name']
|
||||
self.in_args = []
|
||||
self.signature = []
|
||||
for arg in xml_node.findall("arg[@direction='in']"):
|
||||
try:
|
||||
name = arg.attrib['name']
|
||||
except KeyError:
|
||||
name = 'arg{}'.format(len(self.in_args))
|
||||
self.in_args.append(name)
|
||||
self.signature.append(arg.attrib['type'])
|
||||
|
||||
def _make_code_noargs(self):
|
||||
return ("def {name}(self):\n"
|
||||
" return new_method_call(self, '{name}')\n").format(
|
||||
name=self.name)
|
||||
|
||||
def make_code(self):
|
||||
if not self.in_args:
|
||||
return self._make_code_noargs()
|
||||
|
||||
args = ', '.join(self.in_args)
|
||||
signature = ''.join(self.signature)
|
||||
tuple = ('({},)' if len(self.in_args) == 1 else '({})').format(args)
|
||||
return ("def {name}(self, {args}):\n"
|
||||
" return new_method_call(self, '{name}', '{signature}',\n"
|
||||
" {tuple})\n").format(
|
||||
name=self.name, args=args, signature=signature, tuple=tuple
|
||||
)
|
||||
|
||||
INTERFACE_CLASS_TEMPLATE = """
|
||||
class {cls_name}(MessageGenerator):
|
||||
interface = {interface!r}
|
||||
|
||||
def __init__(self, object_path{path_default},
|
||||
bus_name{name_default}):
|
||||
super().__init__(object_path=object_path, bus_name=bus_name)
|
||||
"""
|
||||
|
||||
class Interface:
|
||||
def __init__(self, xml_node, path, bus_name):
|
||||
self.name = xml_node.attrib['name']
|
||||
self.path = path
|
||||
self.bus_name = bus_name
|
||||
self.methods = [Method(node) for node in xml_node.findall('method')]
|
||||
|
||||
def make_code(self):
|
||||
cls_name = self.name.split('.')[-1]
|
||||
chunks = [INTERFACE_CLASS_TEMPLATE.format(
|
||||
cls_name=cls_name,
|
||||
interface=self.name,
|
||||
path_default='' if self.path is None else f'={self.path!r}',
|
||||
name_default='' if self.bus_name is None else f'={self.bus_name!r}'
|
||||
)]
|
||||
for method in self.methods:
|
||||
chunks.append(indent(method.make_code(), ' ' * 4))
|
||||
return '\n'.join(chunks)
|
||||
|
||||
MODULE_TEMPLATE = '''\
|
||||
"""Auto-generated DBus bindings
|
||||
|
||||
Generated by jeepney version {version}
|
||||
|
||||
Object path: {path}
|
||||
Bus name : {bus_name}
|
||||
"""
|
||||
|
||||
from jeepney.wrappers import MessageGenerator, new_method_call
|
||||
|
||||
'''
|
||||
|
||||
# Jeepney already includes bindings for these common interfaces
|
||||
IGNORE_INTERFACES = {
|
||||
'org.freedesktop.DBus.Introspectable',
|
||||
'org.freedesktop.DBus.Properties',
|
||||
'org.freedesktop.DBus.Peer',
|
||||
}
|
||||
|
||||
def code_from_xml(xml, path, bus_name, fh):
|
||||
if isinstance(fh, (bytes, str)):
|
||||
with open(fh, 'w') as f:
|
||||
return code_from_xml(xml, path, bus_name, f)
|
||||
|
||||
root = ET.fromstring(xml)
|
||||
fh.write(MODULE_TEMPLATE.format(version=__version__, path=path,
|
||||
bus_name=bus_name))
|
||||
|
||||
i = 0
|
||||
for interface_node in root.findall('interface'):
|
||||
if interface_node.attrib['name'] in IGNORE_INTERFACES:
|
||||
continue
|
||||
fh.write(Interface(interface_node, path, bus_name).make_code())
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
def generate_from_introspection(path, name, output_file, bus='SESSION'):
|
||||
# Many D-Bus services have a main object at a predictable name, e.g.
|
||||
# org.freedesktop.Notifications -> /org/freedesktop/Notifications
|
||||
if not path:
|
||||
path = '/' + name.replace('.', '/')
|
||||
|
||||
conn = open_dbus_connection(bus)
|
||||
introspectable = Proxy(Introspectable(path, name), conn)
|
||||
xml, = introspectable.Introspect()
|
||||
# print(xml)
|
||||
|
||||
n_interfaces = code_from_xml(xml, path, name, output_file)
|
||||
print("Written {} interface wrappers to {}".format(n_interfaces, output_file))
|
||||
|
||||
def generate_from_file(input_file, path, name, output_file):
|
||||
with open(input_file, encoding='utf-8') as f:
|
||||
xml = f.read()
|
||||
|
||||
n_interfaces = code_from_xml(xml, path, name, output_file)
|
||||
print("Written {} interface wrappers to {}".format(n_interfaces, output_file))
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Generate a simple wrapper module to call D-Bus methods.",
|
||||
epilog="If you don't use --file, this will connect to D-Bus and introspect the "
|
||||
"given name and path. --name and --path can also be used with --file, "
|
||||
"to give defaults for the generated class."
|
||||
)
|
||||
ap.add_argument('-n', '--name',
|
||||
help='Bus name to introspect, required unless using file')
|
||||
ap.add_argument('-p', '--path',
|
||||
help='Object path to introspect. If not specified, a path matching '
|
||||
'the name will be used, e.g. /org/freedesktop/Notifications for org.freedesktop.Notifications')
|
||||
ap.add_argument('--bus', default='SESSION',
|
||||
help='Bus to connect to for introspection (SESSION/SYSTEM), default SESSION')
|
||||
ap.add_argument('-f', '--file',
|
||||
help='XML file to use instead of D-Bus introspection')
|
||||
ap.add_argument('-o', '--output',
|
||||
help='Output filename')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not (args.file or args.name):
|
||||
sys.exit("Either --name or --file is required")
|
||||
|
||||
# If no --output, guess a (hopefully) reasonable name.
|
||||
if args.output:
|
||||
output = args.output
|
||||
elif args.file:
|
||||
output = os.path.splitext(os.path.basename(args.file))[0] + '.py'
|
||||
elif args.path and len(args.path) > 1:
|
||||
output = args.path[1:].replace('/', '_') + '.py'
|
||||
else: # e.g. path is '/'
|
||||
output = args.name.replace('.', '_') + '.py'
|
||||
|
||||
if args.file:
|
||||
generate_from_file(args.file, args.path, args.name, output)
|
||||
else:
|
||||
generate_from_introspection(args.path, args.name, output, args.bus)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
62
lib/jeepney/bus.py
Normal file
62
lib/jeepney/bus.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
_escape_pat = re.compile(r'%([0-9A-Fa-f]{2})')
|
||||
def unescape(v):
|
||||
def repl(match):
|
||||
n = int(match.group(1), base=16)
|
||||
return chr(n)
|
||||
return _escape_pat.sub(repl, v)
|
||||
|
||||
def parse_addresses(s):
|
||||
for addr in s.split(';'):
|
||||
transport, info = addr.split(':', 1)
|
||||
kv = {}
|
||||
for x in info.split(','):
|
||||
k, v = x.split('=', 1)
|
||||
kv[k] = unescape(v)
|
||||
yield (transport, kv)
|
||||
|
||||
SUPPORTED_TRANSPORTS = ('unix',)
|
||||
|
||||
def get_connectable_addresses(addr):
|
||||
unsupported_transports = set()
|
||||
found = False
|
||||
for transport, kv in parse_addresses(addr):
|
||||
if transport not in SUPPORTED_TRANSPORTS:
|
||||
unsupported_transports.add(transport)
|
||||
|
||||
elif transport == 'unix':
|
||||
if 'abstract' in kv:
|
||||
yield '\0' + kv['abstract']
|
||||
found = True
|
||||
elif 'path' in kv:
|
||||
yield kv['path']
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
raise RuntimeError("DBus transports ({}) not supported. Supported: {}"
|
||||
.format(unsupported_transports, SUPPORTED_TRANSPORTS))
|
||||
|
||||
def find_session_bus():
|
||||
addr = os.environ['DBUS_SESSION_BUS_ADDRESS']
|
||||
return next(get_connectable_addresses(addr))
|
||||
# TODO: fallbacks to X, filesystem
|
||||
|
||||
def find_system_bus():
|
||||
addr = os.environ.get('DBUS_SYSTEM_BUS_ADDRESS', '') \
|
||||
or 'unix:path=/var/run/dbus/system_bus_socket'
|
||||
return next(get_connectable_addresses(addr))
|
||||
|
||||
def get_bus(addr):
|
||||
if addr == 'SESSION':
|
||||
return find_session_bus()
|
||||
elif addr == 'SYSTEM':
|
||||
return find_system_bus()
|
||||
else:
|
||||
return next(get_connectable_addresses(addr))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('System bus at:', find_system_bus())
|
||||
print('Session bus at:', find_session_bus())
|
||||
238
lib/jeepney/bus_messages.py
Normal file
238
lib/jeepney/bus_messages.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Messages for talking to the DBus daemon itself
|
||||
|
||||
Generated by jeepney.bindgen and modified by hand.
|
||||
"""
|
||||
from .low_level import Message, MessageType, HeaderFields
|
||||
from .wrappers import MessageGenerator, new_method_call
|
||||
|
||||
__all__ = [
|
||||
'DBusNameFlags',
|
||||
'DBus',
|
||||
'message_bus',
|
||||
'Monitoring',
|
||||
'Stats',
|
||||
'MatchRule',
|
||||
]
|
||||
|
||||
class DBusNameFlags:
|
||||
allow_replacement = 1
|
||||
replace_existing = 2
|
||||
do_not_queue = 4
|
||||
|
||||
class DBus(MessageGenerator):
|
||||
"""Messages to talk to the message bus
|
||||
"""
|
||||
interface = 'org.freedesktop.DBus'
|
||||
|
||||
def __init__(self, object_path='/org/freedesktop/DBus',
|
||||
bus_name='org.freedesktop.DBus'):
|
||||
super().__init__(object_path=object_path, bus_name=bus_name)
|
||||
|
||||
def Hello(self):
|
||||
return new_method_call(self, 'Hello')
|
||||
|
||||
def RequestName(self, name, flags=0):
|
||||
return new_method_call(self, 'RequestName', 'su', (name, flags))
|
||||
|
||||
def ReleaseName(self, name):
|
||||
return new_method_call(self, 'ReleaseName', 's', (name,))
|
||||
|
||||
def StartServiceByName(self, name):
|
||||
return new_method_call(self, 'StartServiceByName', 'su',
|
||||
(name, 0))
|
||||
|
||||
def UpdateActivationEnvironment(self, env):
|
||||
return new_method_call(self, 'UpdateActivationEnvironment', 'a{ss}',
|
||||
(env,))
|
||||
|
||||
def NameHasOwner(self, name):
|
||||
return new_method_call(self, 'NameHasOwner', 's', (name,))
|
||||
|
||||
def ListNames(self):
|
||||
return new_method_call(self, 'ListNames')
|
||||
|
||||
def ListActivatableNames(self):
|
||||
return new_method_call(self, 'ListActivatableNames')
|
||||
|
||||
def AddMatch(self, rule):
|
||||
"""*rule* can be a str or a :class:`MatchRule` instance"""
|
||||
if isinstance(rule, MatchRule):
|
||||
rule = rule.serialise()
|
||||
return new_method_call(self, 'AddMatch', 's', (rule,))
|
||||
|
||||
def RemoveMatch(self, rule):
|
||||
if isinstance(rule, MatchRule):
|
||||
rule = rule.serialise()
|
||||
return new_method_call(self, 'RemoveMatch', 's', (rule,))
|
||||
|
||||
def GetNameOwner(self, name):
|
||||
return new_method_call(self, 'GetNameOwner', 's', (name,))
|
||||
|
||||
def ListQueuedOwners(self, name):
|
||||
return new_method_call(self, 'ListQueuedOwners', 's', (name,))
|
||||
|
||||
def GetConnectionUnixUser(self, name):
|
||||
return new_method_call(self, 'GetConnectionUnixUser', 's', (name,))
|
||||
|
||||
def GetConnectionUnixProcessID(self, name):
|
||||
return new_method_call(self, 'GetConnectionUnixProcessID', 's', (name,))
|
||||
|
||||
def GetAdtAuditSessionData(self, name):
|
||||
return new_method_call(self, 'GetAdtAuditSessionData', 's', (name,))
|
||||
|
||||
def GetConnectionSELinuxSecurityContext(self, name):
|
||||
return new_method_call(self, 'GetConnectionSELinuxSecurityContext', 's',
|
||||
(name,))
|
||||
|
||||
def ReloadConfig(self):
|
||||
return new_method_call(self, 'ReloadConfig')
|
||||
|
||||
def GetId(self):
|
||||
return new_method_call(self, 'GetId')
|
||||
|
||||
def GetConnectionCredentials(self, name):
|
||||
return new_method_call(self, 'GetConnectionCredentials', 's', (name,))
|
||||
|
||||
message_bus = DBus()
|
||||
|
||||
class Monitoring(MessageGenerator):
|
||||
interface = 'org.freedesktop.DBus.Monitoring'
|
||||
|
||||
def __init__(self, object_path='/org/freedesktop/DBus',
|
||||
bus_name='org.freedesktop.DBus'):
|
||||
super().__init__(object_path=object_path, bus_name=bus_name)
|
||||
|
||||
def BecomeMonitor(self, rules):
|
||||
"""Convert this connection to a monitor connection (advanced)"""
|
||||
return new_method_call(self, 'BecomeMonitor', 'asu', (rules, 0))
|
||||
|
||||
class Stats(MessageGenerator):
|
||||
interface = 'org.freedesktop.DBus.Debug.Stats'
|
||||
|
||||
def __init__(self, object_path='/org/freedesktop/DBus',
|
||||
bus_name='org.freedesktop.DBus'):
|
||||
super().__init__(object_path=object_path, bus_name=bus_name)
|
||||
|
||||
def GetStats(self):
|
||||
return new_method_call(self, 'GetStats')
|
||||
|
||||
def GetConnectionStats(self, arg0):
|
||||
return new_method_call(self, 'GetConnectionStats', 's',
|
||||
(arg0,))
|
||||
|
||||
def GetAllMatchRules(self):
|
||||
return new_method_call(self, 'GetAllMatchRules')
|
||||
|
||||
|
||||
class MatchRule:
|
||||
"""Construct a match rule to subscribe to DBus messages.
|
||||
|
||||
e.g.::
|
||||
|
||||
mr = MatchRule(
|
||||
interface='org.freedesktop.DBus',
|
||||
member='NameOwnerChanged',
|
||||
type='signal'
|
||||
)
|
||||
msg = message_bus.AddMatch(mr)
|
||||
# Send this message to subscribe to the signal
|
||||
"""
|
||||
def __init__(self, *, type=None, sender=None, interface=None, member=None,
|
||||
path=None, path_namespace=None, destination=None,
|
||||
eavesdrop=False):
|
||||
if isinstance(type, str):
|
||||
type = MessageType[type]
|
||||
self.message_type = type
|
||||
fields = {
|
||||
'sender': sender,
|
||||
'interface': interface,
|
||||
'member': member,
|
||||
'path': path,
|
||||
'destination': destination,
|
||||
}
|
||||
self.header_fields = {
|
||||
k: v for (k, v) in fields.items() if (v is not None)
|
||||
}
|
||||
self.path_namespace = path_namespace
|
||||
self.eavesdrop = eavesdrop
|
||||
self.arg_conditions = {}
|
||||
|
||||
def add_arg_condition(self, argno: int, value: str, kind='string'):
|
||||
"""Add a condition for a particular argument
|
||||
|
||||
argno: int, 0-63
|
||||
kind: 'string', 'path', 'namespace'
|
||||
"""
|
||||
if kind not in {'string', 'path', 'namespace'}:
|
||||
raise ValueError("kind={!r}".format(kind))
|
||||
if kind == 'namespace' and argno != 0:
|
||||
raise ValueError("argno must be 0 for kind='namespace'")
|
||||
self.arg_conditions[argno] = (value, kind)
|
||||
|
||||
def serialise(self) -> str:
|
||||
"""Convert to a string to use in an AddMatch call to the message bus"""
|
||||
pairs = list(self.header_fields.items())
|
||||
|
||||
if self.message_type:
|
||||
pairs.append(('type', self.message_type.name))
|
||||
|
||||
if self.path_namespace:
|
||||
pairs.append(('path_namespace', self.path_namespace))
|
||||
|
||||
if self.eavesdrop:
|
||||
pairs.append(('eavesdrop', 'true'))
|
||||
|
||||
for argno, (val, kind) in self.arg_conditions.items():
|
||||
if kind == 'string':
|
||||
kind = ''
|
||||
pairs.append((f'arg{argno}{kind}', val))
|
||||
|
||||
# Quoting rules: single quotes ('') needed if the value contains a comma.
|
||||
# A literal ' can only be represented outside single quotes, by
|
||||
# backslash-escaping it. No escaping inside the quotes.
|
||||
# The simplest way to handle this is to use '' around every value, and
|
||||
# use '\'' (end quote, escaped ', restart quote) for literal ' .
|
||||
return ','.join(
|
||||
"{}='{}'".format(k, v.replace("'", r"'\''")) for (k, v) in pairs
|
||||
)
|
||||
|
||||
def matches(self, msg: Message) -> bool:
|
||||
"""Returns True if msg matches this rule"""
|
||||
h = msg.header
|
||||
if (self.message_type is not None) and h.message_type != self.message_type:
|
||||
return False
|
||||
|
||||
for field, expected in self.header_fields.items():
|
||||
if h.fields.get(HeaderFields[field], None) != expected:
|
||||
return False
|
||||
|
||||
if self.path_namespace is not None:
|
||||
path = h.fields.get(HeaderFields.path, '\0')
|
||||
path_ns = self.path_namespace.rstrip('/')
|
||||
if not ((path == path_ns) or path.startswith(path_ns + '/')):
|
||||
return False
|
||||
|
||||
for argno, (expected, kind) in self.arg_conditions.items():
|
||||
if argno >= len(msg.body):
|
||||
return False
|
||||
arg = msg.body[argno]
|
||||
if not isinstance(arg, str):
|
||||
return False
|
||||
if kind == 'string':
|
||||
if arg != expected:
|
||||
return False
|
||||
elif kind == 'path':
|
||||
if not (
|
||||
(arg == expected)
|
||||
or (expected.endswith('/') and arg.startswith(expected))
|
||||
or (arg.endswith('/') and expected.startswith(arg))
|
||||
):
|
||||
return False
|
||||
elif kind == 'namespace':
|
||||
if not (
|
||||
(arg == expected)
|
||||
or arg.startswith(expected + '.')
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
158
lib/jeepney/fds.py
Normal file
158
lib/jeepney/fds.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import array
|
||||
import os
|
||||
import socket
|
||||
from warnings import warn
|
||||
|
||||
|
||||
class NoFDError(RuntimeError):
|
||||
"""Raised by :class:`FileDescriptor` methods if it was already closed/converted
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class FileDescriptor:
|
||||
"""A file descriptor received in a D-Bus message
|
||||
|
||||
This wrapper helps ensure that the file descriptor is closed exactly once.
|
||||
If you don't explicitly convert or close the FileDescriptor object, it will
|
||||
close its file descriptor when it goes out of scope, and emit a
|
||||
ResourceWarning.
|
||||
"""
|
||||
__slots__ = ('_fd',)
|
||||
_CLOSED = -1
|
||||
_CONVERTED = -2
|
||||
|
||||
def __init__(self, fd):
|
||||
self._fd = fd
|
||||
|
||||
def __repr__(self):
|
||||
detail = self._fd
|
||||
if self._fd == self._CLOSED:
|
||||
detail = 'closed'
|
||||
elif self._fd == self._CONVERTED:
|
||||
detail = 'converted'
|
||||
return f"<FileDescriptor ({detail})>"
|
||||
|
||||
def close(self):
|
||||
"""Close the file descriptor
|
||||
|
||||
This can safely be called multiple times, but will raise RuntimeError
|
||||
if called after converting it with one of the ``to_*`` methods.
|
||||
|
||||
This object can also be used in a ``with`` block, to close it on
|
||||
leaving the block.
|
||||
"""
|
||||
if self._fd == self._CLOSED:
|
||||
pass
|
||||
elif self._fd == self._CONVERTED:
|
||||
raise NoFDError("Can't close FileDescriptor after converting it")
|
||||
else:
|
||||
self._fd, fd = self._CLOSED, self._fd
|
||||
os.close(fd)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def __del__(self):
|
||||
if self._fd >= 0:
|
||||
warn(
|
||||
f'FileDescriptor ({self._fd}) was neither closed nor converted',
|
||||
ResourceWarning, stacklevel=2, source=self
|
||||
)
|
||||
self.close()
|
||||
|
||||
def _check(self):
|
||||
if self._fd < 0:
|
||||
detail = 'closed' if self._fd == self._CLOSED else 'converted'
|
||||
raise NoFDError(f'FileDescriptor object was already {detail}')
|
||||
|
||||
def fileno(self):
|
||||
"""Get the integer file descriptor
|
||||
|
||||
This does not change the state of the :class:`FileDescriptor` object,
|
||||
unlike the ``to_*`` methods.
|
||||
"""
|
||||
self._check()
|
||||
return self._fd
|
||||
|
||||
def to_raw_fd(self):
|
||||
"""Convert to the low-level integer file descriptor::
|
||||
|
||||
raw_fd = fd.to_raw_fd()
|
||||
os.write(raw_fd, b'xyz')
|
||||
os.close(raw_fd)
|
||||
|
||||
The :class:`FileDescriptor` can't be used after calling this. The caller
|
||||
is responsible for closing the file descriptor.
|
||||
"""
|
||||
self._check()
|
||||
self._fd, fd = self._CONVERTED, self._fd
|
||||
return fd
|
||||
|
||||
def to_file(self, mode, buffering=-1, encoding=None, errors=None, newline=None):
|
||||
"""Convert to a Python file object::
|
||||
|
||||
with fd.to_file('w') as f:
|
||||
f.write('xyz')
|
||||
|
||||
The arguments are the same as for the builtin :func:`open` function.
|
||||
|
||||
The :class:`FileDescriptor` can't be used after calling this. Closing
|
||||
the file object will also close the file descriptor.
|
||||
"""
|
||||
self._check()
|
||||
f = open(
|
||||
self._fd, mode, buffering=buffering,
|
||||
encoding=encoding, errors=errors, newline=newline
|
||||
)
|
||||
self._fd = self._CONVERTED
|
||||
return f
|
||||
|
||||
def to_socket(self):
|
||||
"""Convert to a socket object
|
||||
|
||||
This returns a standard library :func:`socket.socket` object::
|
||||
|
||||
with fd.to_socket() as sock:
|
||||
b = sock.sendall(b'xyz')
|
||||
|
||||
The wrapper object can't be used after calling this. Closing the socket
|
||||
object will also close the file descriptor.
|
||||
"""
|
||||
from socket import socket
|
||||
|
||||
self._check()
|
||||
s = socket(fileno=self._fd)
|
||||
self._fd = self._CONVERTED
|
||||
return s
|
||||
|
||||
@classmethod
|
||||
def from_ancdata(cls, ancdata) -> ['FileDescriptor']:
|
||||
"""Make a list of FileDescriptor from received file descriptors
|
||||
|
||||
ancdata is a list of ancillary data tuples as returned by socket.recvmsg()
|
||||
"""
|
||||
fds = array.array("i") # Array of ints
|
||||
for cmsg_level, cmsg_type, data in ancdata:
|
||||
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
|
||||
# Append data, ignoring any truncated integers at the end.
|
||||
fds.frombytes(data[:len(data) - (len(data) % fds.itemsize)])
|
||||
return [cls(i) for i in fds]
|
||||
|
||||
|
||||
_fds_buf_size_cache = None
|
||||
|
||||
def fds_buf_size():
|
||||
# If there may be file descriptors, we try to read 1 message at a time.
|
||||
# The reference implementation of D-Bus defaults to allowing 16 FDs per
|
||||
# message, and the Linux kernel currently allows 253 FDs per sendmsg()
|
||||
# call. So hopefully allowing 256 FDs per recvmsg() will always suffice.
|
||||
global _fds_buf_size_cache
|
||||
if _fds_buf_size_cache is None:
|
||||
maxfds = 256
|
||||
fd_size = array.array('i').itemsize
|
||||
_fds_buf_size_cache = socket.CMSG_SPACE(maxfds * fd_size)
|
||||
return _fds_buf_size_cache
|
||||
1
lib/jeepney/io/__init__.py
Normal file
1
lib/jeepney/io/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .common import RouterClosed
|
||||
BIN
lib/jeepney/io/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/__pycache__/asyncio.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/__pycache__/asyncio.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/__pycache__/blocking.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/__pycache__/blocking.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/__pycache__/common.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/__pycache__/common.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/__pycache__/threading.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/__pycache__/threading.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/__pycache__/trio.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/__pycache__/trio.cpython-314.pyc
Normal file
Binary file not shown.
233
lib/jeepney/io/asyncio.py
Normal file
233
lib/jeepney/io/asyncio.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
from itertools import count
|
||||
from typing import Optional
|
||||
|
||||
from jeepney.auth import Authenticator, BEGIN
|
||||
from jeepney.bus import get_bus
|
||||
from jeepney import Message, MessageType, Parser
|
||||
from jeepney.wrappers import ProxyBase, unwrap_msg
|
||||
from jeepney.bus_messages import message_bus
|
||||
from .common import (
|
||||
MessageFilters, FilterHandle, ReplyMatcher, RouterClosed, check_replyable,
|
||||
)
|
||||
|
||||
|
||||
class DBusConnection:
|
||||
"""A plain D-Bus connection with no matching of replies.
|
||||
|
||||
This doesn't run any separate tasks: sending and receiving are done in
|
||||
the task that calls those methods. It's suitable for implementing servers:
|
||||
several worker tasks can receive requests and send replies.
|
||||
For a typical client pattern, see :class:`DBusRouter`.
|
||||
"""
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.parser = Parser()
|
||||
self.outgoing_serial = count(start=1)
|
||||
self.unique_name = None
|
||||
self.send_lock = asyncio.Lock()
|
||||
|
||||
async def send(self, message: Message, *, serial=None):
|
||||
"""Serialise and send a :class:`~.Message` object"""
|
||||
async with self.send_lock:
|
||||
if serial is None:
|
||||
serial = next(self.outgoing_serial)
|
||||
self.writer.write(message.serialise(serial))
|
||||
await self.writer.drain()
|
||||
|
||||
async def receive(self) -> Message:
|
||||
"""Return the next available message from the connection"""
|
||||
while True:
|
||||
msg = self.parser.get_next_message()
|
||||
if msg is not None:
|
||||
return msg
|
||||
|
||||
b = await self.reader.read(4096)
|
||||
if not b:
|
||||
raise EOFError
|
||||
self.parser.add_data(b)
|
||||
|
||||
async def close(self):
|
||||
"""Close the D-Bus connection"""
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
|
||||
|
||||
async def open_dbus_connection(bus='SESSION'):
|
||||
"""Open a plain D-Bus connection
|
||||
|
||||
:return: :class:`DBusConnection`
|
||||
"""
|
||||
bus_addr = get_bus(bus)
|
||||
reader, writer = await asyncio.open_unix_connection(bus_addr)
|
||||
|
||||
# Authentication flow
|
||||
authr = Authenticator()
|
||||
for req_data in authr:
|
||||
writer.write(req_data)
|
||||
await writer.drain()
|
||||
b = await reader.read(1024)
|
||||
if not b:
|
||||
raise EOFError("Socket closed before authentication")
|
||||
authr.feed(b)
|
||||
|
||||
writer.write(BEGIN)
|
||||
await writer.drain()
|
||||
# Authentication finished
|
||||
|
||||
conn = DBusConnection(reader, writer)
|
||||
|
||||
# Say *Hello* to the message bus - this must be the first message, and the
|
||||
# reply gives us our unique name.
|
||||
async with DBusRouter(conn) as router:
|
||||
reply_body = await asyncio.wait_for(Proxy(message_bus, router).Hello(), 10)
|
||||
conn.unique_name = reply_body[0]
|
||||
|
||||
return conn
|
||||
|
||||
class DBusRouter:
|
||||
"""A 'client' D-Bus connection which can wait for a specific reply.
|
||||
|
||||
This runs a background receiver task, and makes it possible to send a
|
||||
request and wait for the relevant reply.
|
||||
"""
|
||||
_nursery_mgr = None
|
||||
_send_cancel_scope = None
|
||||
_rcv_cancel_scope = None
|
||||
|
||||
def __init__(self, conn: DBusConnection):
|
||||
self._conn = conn
|
||||
self._replies = ReplyMatcher()
|
||||
self._filters = MessageFilters()
|
||||
self._rcv_task = asyncio.create_task(self._receiver())
|
||||
|
||||
@property
|
||||
def unique_name(self):
|
||||
return self._conn.unique_name
|
||||
|
||||
async def send(self, message, *, serial=None):
|
||||
"""Send a message, don't wait for a reply"""
|
||||
await self._conn.send(message, serial=serial)
|
||||
|
||||
async def send_and_get_reply(self, message) -> Message:
|
||||
"""Send a method call message and wait for the reply
|
||||
|
||||
Returns the reply message (method return or error message type).
|
||||
"""
|
||||
check_replyable(message)
|
||||
if self._rcv_task.done():
|
||||
raise RouterClosed("This DBusRouter has stopped")
|
||||
|
||||
serial = next(self._conn.outgoing_serial)
|
||||
|
||||
with self._replies.catch(serial, asyncio.Future()) as reply_fut:
|
||||
await self.send(message, serial=serial)
|
||||
return (await reply_fut)
|
||||
|
||||
def filter(self, rule, *, queue: Optional[asyncio.Queue] =None, bufsize=1):
|
||||
"""Create a filter for incoming messages
|
||||
|
||||
Usage::
|
||||
|
||||
with router.filter(rule) as queue:
|
||||
matching_msg = await queue.get()
|
||||
|
||||
:param MatchRule rule: Catch messages matching this rule
|
||||
:param asyncio.Queue queue: Send matching messages here
|
||||
:param int bufsize: If no queue is passed in, create one with this size
|
||||
"""
|
||||
return FilterHandle(self._filters, rule, queue or asyncio.Queue(bufsize))
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self._rcv_task.done():
|
||||
self._rcv_task.result() # Throw exception if receive task failed
|
||||
else:
|
||||
self._rcv_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._rcv_task
|
||||
return False
|
||||
|
||||
# Code to run in receiver task ------------------------------------
|
||||
|
||||
def _dispatch(self, msg: Message):
|
||||
"""Handle one received message"""
|
||||
if self._replies.dispatch(msg):
|
||||
return
|
||||
|
||||
for filter in list(self._filters.matches(msg)):
|
||||
try:
|
||||
filter.queue.put_nowait(msg)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
async def _receiver(self):
|
||||
"""Receiver loop - runs in a separate task"""
|
||||
try:
|
||||
while True:
|
||||
msg = await self._conn.receive()
|
||||
self._dispatch(msg)
|
||||
finally:
|
||||
# Send errors to any tasks still waiting for a message.
|
||||
self._replies.drop_all()
|
||||
|
||||
class open_dbus_router:
|
||||
"""Open a D-Bus 'router' to send and receive messages
|
||||
|
||||
Use as an async context manager::
|
||||
|
||||
async with open_dbus_router() as router:
|
||||
...
|
||||
"""
|
||||
conn = None
|
||||
req_ctx = None
|
||||
|
||||
def __init__(self, bus='SESSION'):
|
||||
self.bus = bus
|
||||
|
||||
async def __aenter__(self):
|
||||
self.conn = await open_dbus_connection(self.bus)
|
||||
self.req_ctx = DBusRouter(self.conn)
|
||||
return await self.req_ctx.__aenter__()
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.req_ctx.__aexit__(exc_type, exc_val, exc_tb)
|
||||
await self.conn.close()
|
||||
|
||||
|
||||
class Proxy(ProxyBase):
|
||||
"""An asyncio proxy for calling D-Bus methods
|
||||
|
||||
You can call methods on the proxy object, such as ``await bus_proxy.Hello()``
|
||||
to make a method call over D-Bus and wait for a reply. It will either
|
||||
return a tuple of returned data, or raise :exc:`.DBusErrorResponse`.
|
||||
The methods available are defined by the message generator you wrap.
|
||||
|
||||
:param msggen: A message generator object.
|
||||
:param ~asyncio.DBusRouter router: Router to send and receive messages.
|
||||
"""
|
||||
def __init__(self, msggen, router):
|
||||
super().__init__(msggen)
|
||||
self._router = router
|
||||
|
||||
def __repr__(self):
|
||||
return 'Proxy({}, {})'.format(self._msggen, self._router)
|
||||
|
||||
def _method_call(self, make_msg):
|
||||
async def inner(*args, **kwargs):
|
||||
msg = make_msg(*args, **kwargs)
|
||||
assert msg.header.message_type is MessageType.method_call
|
||||
reply = await self._router.send_and_get_reply(msg)
|
||||
return unwrap_msg(reply)
|
||||
|
||||
return inner
|
||||
337
lib/jeepney/io/blocking.py
Normal file
337
lib/jeepney/io/blocking.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""Synchronous IO wrappers around jeepney
|
||||
"""
|
||||
import array
|
||||
from collections import deque
|
||||
from errno import ECONNRESET
|
||||
import functools
|
||||
from itertools import count
|
||||
import os
|
||||
from selectors import DefaultSelector, EVENT_READ
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from jeepney import Parser, Message, MessageType, HeaderFields
|
||||
from jeepney.auth import Authenticator, BEGIN
|
||||
from jeepney.bus import get_bus
|
||||
from jeepney.fds import FileDescriptor, fds_buf_size
|
||||
from jeepney.wrappers import ProxyBase, unwrap_msg
|
||||
from jeepney.bus_messages import message_bus
|
||||
from .common import MessageFilters, FilterHandle, check_replyable
|
||||
|
||||
__all__ = [
|
||||
'open_dbus_connection',
|
||||
'DBusConnection',
|
||||
'Proxy',
|
||||
]
|
||||
|
||||
|
||||
class _Future:
|
||||
def __init__(self):
|
||||
self._result = None
|
||||
|
||||
def done(self):
|
||||
return bool(self._result)
|
||||
|
||||
def set_exception(self, exception):
|
||||
self._result = (False, exception)
|
||||
|
||||
def set_result(self, result):
|
||||
self._result = (True, result)
|
||||
|
||||
def result(self):
|
||||
success, value = self._result
|
||||
if success:
|
||||
return value
|
||||
raise value
|
||||
|
||||
|
||||
def timeout_to_deadline(timeout):
|
||||
if timeout is not None:
|
||||
return time.monotonic() + timeout
|
||||
return None
|
||||
|
||||
def deadline_to_timeout(deadline):
|
||||
if deadline is not None:
|
||||
return max(deadline - time.monotonic(), 0.)
|
||||
return None
|
||||
|
||||
|
||||
class DBusConnectionBase:
|
||||
"""Connection machinery shared by this module and threading"""
|
||||
def __init__(self, sock: socket.socket, enable_fds=False):
|
||||
self.sock = sock
|
||||
self.enable_fds = enable_fds
|
||||
self.parser = Parser()
|
||||
self.outgoing_serial = count(start=1)
|
||||
self.selector = DefaultSelector()
|
||||
self.select_key = self.selector.register(sock, EVENT_READ)
|
||||
self.unique_name = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def _serialise(self, message: Message, serial) -> (bytes, Optional[array.array]):
|
||||
if serial is None:
|
||||
serial = next(self.outgoing_serial)
|
||||
fds = array.array('i') if self.enable_fds else None
|
||||
data = message.serialise(serial=serial, fds=fds)
|
||||
return data, fds
|
||||
|
||||
def _send_with_fds(self, data, fds):
|
||||
bytes_sent = self.sock.sendmsg(
|
||||
[data], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds)]
|
||||
)
|
||||
# If sendmsg succeeds, I think ancillary data has been sent atomically?
|
||||
# So now we just need to send any leftover normal data.
|
||||
if bytes_sent < len(data):
|
||||
self.sock.sendall(data[bytes_sent:])
|
||||
|
||||
def _receive(self, deadline):
|
||||
while True:
|
||||
msg = self.parser.get_next_message()
|
||||
if msg is not None:
|
||||
return msg
|
||||
|
||||
b, fds = self._read_some_data(timeout=deadline_to_timeout(deadline))
|
||||
self.parser.add_data(b, fds=fds)
|
||||
|
||||
def _read_some_data(self, timeout=None):
|
||||
for key, ev in self.selector.select(timeout):
|
||||
if key == self.select_key:
|
||||
if self.enable_fds:
|
||||
return self._read_with_fds()
|
||||
else:
|
||||
return unwrap_read(self.sock.recv(4096)), []
|
||||
|
||||
raise TimeoutError
|
||||
|
||||
def _read_with_fds(self):
|
||||
nbytes = self.parser.bytes_desired()
|
||||
data, ancdata, flags, _ = self.sock.recvmsg(nbytes, fds_buf_size())
|
||||
if flags & getattr(socket, 'MSG_CTRUNC', 0):
|
||||
self.close()
|
||||
raise RuntimeError("Unable to receive all file descriptors")
|
||||
return unwrap_read(data), FileDescriptor.from_ancdata(ancdata)
|
||||
|
||||
def close(self):
|
||||
"""Close the connection"""
|
||||
self.selector.close()
|
||||
self.sock.close()
|
||||
|
||||
|
||||
class DBusConnection(DBusConnectionBase):
|
||||
def __init__(self, sock: socket.socket, enable_fds=False):
|
||||
super().__init__(sock, enable_fds)
|
||||
|
||||
# Message routing machinery
|
||||
self._filters = MessageFilters()
|
||||
|
||||
# Say Hello, get our unique name
|
||||
self.bus_proxy = Proxy(message_bus, self)
|
||||
hello_reply = self.bus_proxy.Hello()
|
||||
self.unique_name = hello_reply[0]
|
||||
|
||||
def send(self, message: Message, serial=None):
|
||||
"""Serialise and send a :class:`~.Message` object"""
|
||||
data, fds = self._serialise(message, serial)
|
||||
if fds:
|
||||
self._send_with_fds(data, fds)
|
||||
else:
|
||||
self.sock.sendall(data)
|
||||
|
||||
send_message = send # Backwards compatibility
|
||||
|
||||
def receive(self, *, timeout=None) -> Message:
|
||||
"""Return the next available message from the connection
|
||||
|
||||
If the data is ready, this will return immediately, even if timeout<=0.
|
||||
Otherwise, it will wait for up to timeout seconds, or indefinitely if
|
||||
timeout is None. If no message comes in time, it raises TimeoutError.
|
||||
"""
|
||||
return self._receive(timeout_to_deadline(timeout))
|
||||
|
||||
def recv_messages(self, *, timeout=None):
|
||||
"""Receive one message and apply filters
|
||||
|
||||
See :meth:`filter`. Returns nothing.
|
||||
"""
|
||||
msg = self.receive(timeout=timeout)
|
||||
for filter in self._filters.matches(msg):
|
||||
filter.queue.append(msg)
|
||||
|
||||
def send_and_get_reply(self, message, *, timeout=None):
|
||||
"""Send a message, wait for the reply and return it
|
||||
|
||||
Filters are applied to other messages received before the reply -
|
||||
see :meth:`add_filter`.
|
||||
"""
|
||||
check_replyable(message)
|
||||
deadline = timeout_to_deadline(timeout)
|
||||
|
||||
serial = next(self.outgoing_serial)
|
||||
self.send_message(message, serial=serial)
|
||||
while True:
|
||||
msg_in = self.receive(timeout=deadline_to_timeout(deadline))
|
||||
reply_to = msg_in.header.fields.get(HeaderFields.reply_serial, -1)
|
||||
if reply_to == serial:
|
||||
return msg_in
|
||||
|
||||
# Not the reply
|
||||
for filter in self._filters.matches(msg_in):
|
||||
filter.queue.append(msg_in)
|
||||
|
||||
def filter(self, rule, *, queue: Optional[deque] =None, bufsize=1):
|
||||
"""Create a filter for incoming messages
|
||||
|
||||
Usage::
|
||||
|
||||
with conn.filter(rule) as matches:
|
||||
# matches is a deque containing matched messages
|
||||
matching_msg = conn.recv_until_filtered(matches)
|
||||
|
||||
:param jeepney.MatchRule rule: Catch messages matching this rule
|
||||
:param collections.deque queue: Matched messages will be added to this
|
||||
:param int bufsize: If no deque is passed in, create one with this size
|
||||
"""
|
||||
if queue is None:
|
||||
queue = deque(maxlen=bufsize)
|
||||
return FilterHandle(self._filters, rule, queue)
|
||||
|
||||
def recv_until_filtered(self, queue, *, timeout=None) -> Message:
|
||||
"""Process incoming messages until one is filtered into queue
|
||||
|
||||
Pops the message from queue and returns it, or raises TimeoutError if
|
||||
the optional timeout expires. Without a timeout, this is equivalent to::
|
||||
|
||||
while len(queue) == 0:
|
||||
conn.recv_messages()
|
||||
return queue.popleft()
|
||||
|
||||
In the other I/O modules, there is no need for this, because messages
|
||||
are placed in queues by a separate task.
|
||||
|
||||
:param collections.deque queue: A deque connected by :meth:`filter`
|
||||
:param float timeout: Maximum time to wait in seconds
|
||||
"""
|
||||
deadline = timeout_to_deadline(timeout)
|
||||
while len(queue) == 0:
|
||||
self.recv_messages(timeout=deadline_to_timeout(deadline))
|
||||
return queue.popleft()
|
||||
|
||||
|
||||
class Proxy(ProxyBase):
|
||||
"""A blocking proxy for calling D-Bus methods
|
||||
|
||||
You can call methods on the proxy object, such as ``bus_proxy.Hello()``
|
||||
to make a method call over D-Bus and wait for a reply. It will either
|
||||
return a tuple of returned data, or raise :exc:`.DBusErrorResponse`.
|
||||
The methods available are defined by the message generator you wrap.
|
||||
|
||||
You can set a time limit on a call by passing ``_timeout=`` in the method
|
||||
call, or set a default when creating the proxy. The ``_timeout`` argument
|
||||
is not passed to the message generator.
|
||||
All timeouts are in seconds, and :exc:`TimeoutErrror` is raised if it
|
||||
expires before a reply arrives.
|
||||
|
||||
:param msggen: A message generator object
|
||||
:param ~blocking.DBusConnection connection: Connection to send and receive messages
|
||||
:param float timeout: Default seconds to wait for a reply, or None for no limit
|
||||
"""
|
||||
def __init__(self, msggen, connection, *, timeout=None):
|
||||
super().__init__(msggen)
|
||||
self._connection = connection
|
||||
self._timeout = timeout
|
||||
|
||||
def __repr__(self):
|
||||
extra = '' if (self._timeout is None) else f', timeout={self._timeout}'
|
||||
return f"Proxy({self._msggen}, {self._connection}{extra})"
|
||||
|
||||
def _method_call(self, make_msg):
|
||||
@functools.wraps(make_msg)
|
||||
def inner(*args, **kwargs):
|
||||
timeout = kwargs.pop('_timeout', self._timeout)
|
||||
msg = make_msg(*args, **kwargs)
|
||||
assert msg.header.message_type is MessageType.method_call
|
||||
return unwrap_msg(self._connection.send_and_get_reply(
|
||||
msg, timeout=timeout
|
||||
))
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def unwrap_read(b):
|
||||
"""Raise ConnectionResetError from an empty read.
|
||||
|
||||
Sometimes the socket raises an error itself, sometimes it gives no data.
|
||||
I haven't worked out when it behaves each way.
|
||||
"""
|
||||
if not b:
|
||||
raise ConnectionResetError(ECONNRESET, os.strerror(ECONNRESET))
|
||||
return b
|
||||
|
||||
|
||||
def prep_socket(addr, enable_fds=False, timeout=2.0) -> socket.socket:
|
||||
"""Create a socket and authenticate ready to send D-Bus messages"""
|
||||
sock = socket.socket(family=socket.AF_UNIX)
|
||||
|
||||
# To impose the overall auth timeout, we'll update the timeout on the socket
|
||||
# before each send/receive. This is ugly, but we can't use the socket for
|
||||
# anything else until this has succeeded, so this should be safe.
|
||||
deadline = timeout_to_deadline(timeout)
|
||||
def with_sock_deadline(meth, *args):
|
||||
sock.settimeout(deadline_to_timeout(deadline))
|
||||
return meth(*args)
|
||||
|
||||
try:
|
||||
with_sock_deadline(sock.connect, addr)
|
||||
authr = Authenticator(enable_fds=enable_fds, inc_null_byte=False)
|
||||
if hasattr(socket, 'SCM_CREDS'):
|
||||
# BSD: send credentials message to authenticate (kernel fills in data)
|
||||
sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_CREDS, bytes(512))])
|
||||
else:
|
||||
# Linux: no ancillary data needed, bus checks with SO_PEERCRED
|
||||
sock.send(b'\0')
|
||||
for req_data in authr:
|
||||
with_sock_deadline(sock.sendall, req_data)
|
||||
authr.feed(unwrap_read(with_sock_deadline(sock.recv, 1024)))
|
||||
with_sock_deadline(sock.sendall, BEGIN)
|
||||
except socket.timeout as e:
|
||||
sock.close()
|
||||
raise TimeoutError(f"Did not authenticate in {timeout} seconds") from e
|
||||
except:
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
sock.settimeout(None) # Put the socket back in blocking mode
|
||||
return sock
|
||||
|
||||
|
||||
def open_dbus_connection(
|
||||
bus='SESSION', enable_fds=False, auth_timeout=1.,
|
||||
) -> DBusConnection:
|
||||
"""Connect to a D-Bus message bus
|
||||
|
||||
Pass ``enable_fds=True`` to allow sending & receiving file descriptors.
|
||||
An error will be raised if the bus does not allow this. For simplicity,
|
||||
it's advisable to leave this disabled unless you need it.
|
||||
|
||||
D-Bus has an authentication step before sending or receiving messages.
|
||||
This takes < 1 ms in normal operation, but there is a timeout so that client
|
||||
code won't get stuck if the server doesn't reply. *auth_timeout* configures
|
||||
this timeout in seconds.
|
||||
"""
|
||||
bus_addr = get_bus(bus)
|
||||
sock = prep_socket(bus_addr, enable_fds, timeout=auth_timeout)
|
||||
|
||||
conn = DBusConnection(sock, enable_fds)
|
||||
return conn
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
conn = open_dbus_connection()
|
||||
print("Unique name:", conn.unique_name)
|
||||
88
lib/jeepney/io/common.py
Normal file
88
lib/jeepney/io/common.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from contextlib import contextmanager
|
||||
from itertools import count
|
||||
|
||||
from jeepney import HeaderFields, Message, MessageFlag, MessageType
|
||||
|
||||
class MessageFilters:
|
||||
def __init__(self):
|
||||
self.filters = {}
|
||||
self.filter_ids = count()
|
||||
|
||||
def matches(self, message):
|
||||
for handle in self.filters.values():
|
||||
if handle.rule.matches(message):
|
||||
yield handle
|
||||
|
||||
|
||||
class FilterHandle:
|
||||
def __init__(self, filters: MessageFilters, rule, queue):
|
||||
self._filters = filters
|
||||
self._filter_id = next(filters.filter_ids)
|
||||
self.rule = rule
|
||||
self.queue = queue
|
||||
|
||||
self._filters.filters[self._filter_id] = self
|
||||
|
||||
def close(self):
|
||||
del self._filters.filters[self._filter_id]
|
||||
|
||||
def __enter__(self):
|
||||
return self.queue
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
class ReplyMatcher:
|
||||
def __init__(self):
|
||||
self._futures = {}
|
||||
|
||||
@contextmanager
|
||||
def catch(self, serial, future):
|
||||
"""Context manager to capture a reply for the given serial number"""
|
||||
self._futures[serial] = future
|
||||
|
||||
try:
|
||||
yield future
|
||||
finally:
|
||||
del self._futures[serial]
|
||||
|
||||
def dispatch(self, msg):
|
||||
"""Dispatch an incoming message which may be a reply
|
||||
|
||||
Returns True if a task was waiting for it, otherwise False.
|
||||
"""
|
||||
rep_serial = msg.header.fields.get(HeaderFields.reply_serial, -1)
|
||||
if rep_serial in self._futures:
|
||||
self._futures[rep_serial].set_result(msg)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def drop_all(self, exc: Exception = None):
|
||||
"""Throw an error in any task still waiting for a reply"""
|
||||
if exc is None:
|
||||
exc = RouterClosed("D-Bus router closed before reply arrived")
|
||||
futures, self._futures = self._futures, {}
|
||||
for fut in futures.values():
|
||||
fut.set_exception(exc)
|
||||
|
||||
|
||||
class RouterClosed(Exception):
|
||||
"""Raised in tasks waiting for a reply when the router is closed
|
||||
|
||||
This will also be raised if the receiver task crashes, so tasks are not
|
||||
stuck waiting for a reply that can never come. The router object will not
|
||||
be usable after this is raised.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def check_replyable(msg: Message):
|
||||
"""Raise an error if we wouldn't expect a reply for msg"""
|
||||
if msg.header.message_type != MessageType.method_call:
|
||||
raise TypeError("Only method call messages have replies "
|
||||
f"(not {msg.header.message_type})")
|
||||
if MessageFlag.no_reply_expected & msg.header.flags:
|
||||
raise ValueError("This message has the no_reply_expected flag set")
|
||||
0
lib/jeepney/io/tests/__init__.py
Normal file
0
lib/jeepney/io/tests/__init__.py
Normal file
BIN
lib/jeepney/io/tests/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/tests/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/tests/__pycache__/conftest.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/tests/__pycache__/conftest.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/tests/__pycache__/test_asyncio.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/tests/__pycache__/test_asyncio.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/tests/__pycache__/test_blocking.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/tests/__pycache__/test_blocking.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/tests/__pycache__/test_threading.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/tests/__pycache__/test_threading.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/tests/__pycache__/test_trio.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/tests/__pycache__/test_trio.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/io/tests/__pycache__/utils.cpython-314.pyc
Normal file
BIN
lib/jeepney/io/tests/__pycache__/utils.cpython-314.pyc
Normal file
Binary file not shown.
81
lib/jeepney/io/tests/conftest.py
Normal file
81
lib/jeepney/io/tests/conftest.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from tempfile import TemporaryFile
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from jeepney import (
|
||||
DBusAddress, HeaderFields, message_bus, MessageType, new_error,
|
||||
new_method_return,
|
||||
)
|
||||
from jeepney.io.threading import open_dbus_connection, DBusRouter, Proxy
|
||||
|
||||
@pytest.fixture()
|
||||
def respond_with_fd():
|
||||
name = "io.gitlab.takluyver.jeepney.tests.respond_with_fd"
|
||||
addr = DBusAddress(bus_name=name, object_path='/')
|
||||
|
||||
with open_dbus_connection(bus='SESSION', enable_fds=True) as conn:
|
||||
with DBusRouter(conn) as router:
|
||||
status, = Proxy(message_bus, router).RequestName(name)
|
||||
assert status == 1 # DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER
|
||||
|
||||
def _reply_once():
|
||||
while True:
|
||||
msg = conn.receive()
|
||||
if msg.header.message_type is MessageType.method_call:
|
||||
if msg.header.fields[HeaderFields.member] == 'GetFD':
|
||||
with TemporaryFile('w+') as tf:
|
||||
tf.write('readme')
|
||||
tf.seek(0)
|
||||
rep = new_method_return(msg, 'h', (tf,))
|
||||
conn.send(rep)
|
||||
return
|
||||
else:
|
||||
conn.send(new_error(msg, 'NoMethod'))
|
||||
|
||||
reply_thread = threading.Thread(target=_reply_once, daemon=True)
|
||||
reply_thread.start()
|
||||
yield addr
|
||||
|
||||
reply_thread.join()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def read_from_fd():
|
||||
name = "io.gitlab.takluyver.jeepney.tests.read_from_fd"
|
||||
addr = DBusAddress(bus_name=name, object_path='/')
|
||||
|
||||
with open_dbus_connection(bus='SESSION', enable_fds=True) as conn:
|
||||
with DBusRouter(conn) as router:
|
||||
status, = Proxy(message_bus, router).RequestName(name)
|
||||
assert status == 1 # DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER
|
||||
|
||||
def _reply_once():
|
||||
while True:
|
||||
msg = conn.receive()
|
||||
if msg.header.message_type is MessageType.method_call:
|
||||
if msg.header.fields[HeaderFields.member] == 'ReadFD':
|
||||
with msg.body[0].to_file('rb') as f:
|
||||
f.seek(0)
|
||||
b = f.read()
|
||||
conn.send(new_method_return(msg, 'ay', (b,)))
|
||||
return
|
||||
else:
|
||||
conn.send(new_error(msg, 'NoMethod'))
|
||||
|
||||
reply_thread = threading.Thread(target=_reply_once, daemon=True)
|
||||
reply_thread.start()
|
||||
yield addr
|
||||
|
||||
reply_thread.join()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def temp_file_and_contents():
|
||||
data = b'abc123'
|
||||
with TemporaryFile('w+b') as tf:
|
||||
tf.write(data)
|
||||
tf.flush()
|
||||
tf.seek(0)
|
||||
yield tf, data
|
||||
|
||||
95
lib/jeepney/io/tests/test_asyncio.py
Normal file
95
lib/jeepney/io/tests/test_asyncio.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from asyncio import timeout
|
||||
else:
|
||||
from async_timeout import timeout
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from jeepney import DBusAddress, new_method_call
|
||||
from jeepney.bus_messages import message_bus, MatchRule
|
||||
from jeepney.io.asyncio import (
|
||||
open_dbus_connection, open_dbus_router, Proxy
|
||||
)
|
||||
from .utils import have_session_bus
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.asyncio,
|
||||
pytest.mark.skipif(
|
||||
not have_session_bus, reason="Tests require DBus session bus"
|
||||
),
|
||||
]
|
||||
|
||||
bus_peer = DBusAddress(
|
||||
bus_name='org.freedesktop.DBus',
|
||||
object_path='/org/freedesktop/DBus',
|
||||
interface='org.freedesktop.DBus.Peer'
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def connection():
|
||||
async with (await open_dbus_connection(bus='SESSION')) as conn:
|
||||
yield conn
|
||||
|
||||
async def test_connect(connection):
|
||||
assert connection.unique_name.startswith(':')
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def router():
|
||||
async with open_dbus_router(bus='SESSION') as router:
|
||||
yield router
|
||||
|
||||
async def test_send_and_get_reply(router):
|
||||
ping_call = new_method_call(bus_peer, 'Ping')
|
||||
reply = await asyncio.wait_for(
|
||||
router.send_and_get_reply(ping_call), timeout=5
|
||||
)
|
||||
assert reply.body == ()
|
||||
|
||||
async def test_proxy(router):
|
||||
proxy = Proxy(message_bus, router)
|
||||
name = "io.gitlab.takluyver.jeepney.examples.Server"
|
||||
res = await proxy.RequestName(name)
|
||||
assert res in {(1,), (2,)} # 1: got the name, 2: queued
|
||||
|
||||
has_owner, = await proxy.NameHasOwner(name)
|
||||
assert has_owner is True
|
||||
|
||||
async def test_filter(router):
|
||||
bus = Proxy(message_bus, router)
|
||||
name = "io.gitlab.takluyver.jeepney.tests.asyncio_test_filter"
|
||||
|
||||
match_rule = MatchRule(
|
||||
type="signal",
|
||||
sender=message_bus.bus_name,
|
||||
interface=message_bus.interface,
|
||||
member="NameOwnerChanged",
|
||||
path=message_bus.object_path,
|
||||
)
|
||||
match_rule.add_arg_condition(0, name)
|
||||
|
||||
# Ask the message bus to subscribe us to this signal
|
||||
await bus.AddMatch(match_rule)
|
||||
|
||||
with router.filter(match_rule) as queue:
|
||||
res, = await bus.RequestName(name)
|
||||
assert res == 1 # 1: got the name
|
||||
|
||||
signal_msg = await asyncio.wait_for(queue.get(), timeout=2.0)
|
||||
assert signal_msg.body == (name, '', router.unique_name)
|
||||
|
||||
async def test_recv_after_connect():
|
||||
# Can't use here:
|
||||
# 1. 'connection' fixture
|
||||
# 2. asyncio.wait_for()
|
||||
# If (1) and/or (2) is used, the error won't be triggered.
|
||||
conn = await open_dbus_connection(bus='SESSION')
|
||||
try:
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
async with timeout(0):
|
||||
await conn.receive()
|
||||
finally:
|
||||
await conn.close()
|
||||
84
lib/jeepney/io/tests/test_blocking.py
Normal file
84
lib/jeepney/io/tests/test_blocking.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
|
||||
from jeepney import new_method_call, MessageType, DBusAddress
|
||||
from jeepney.bus_messages import message_bus, MatchRule
|
||||
from jeepney.io.blocking import open_dbus_connection, Proxy
|
||||
from .utils import have_session_bus
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not have_session_bus, reason="Tests require DBus session bus"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def session_conn():
|
||||
with open_dbus_connection(bus='SESSION') as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def test_connect(session_conn):
|
||||
assert session_conn.unique_name.startswith(':')
|
||||
|
||||
bus_peer = DBusAddress(
|
||||
bus_name='org.freedesktop.DBus',
|
||||
object_path='/org/freedesktop/DBus',
|
||||
interface='org.freedesktop.DBus.Peer'
|
||||
)
|
||||
|
||||
def test_send_and_get_reply(session_conn):
|
||||
ping_call = new_method_call(bus_peer, 'Ping')
|
||||
reply = session_conn.send_and_get_reply(ping_call, timeout=5)
|
||||
assert reply.header.message_type == MessageType.method_return
|
||||
assert reply.body == ()
|
||||
|
||||
def test_proxy(session_conn):
|
||||
proxy = Proxy(message_bus, session_conn, timeout=5)
|
||||
name = "io.gitlab.takluyver.jeepney.examples.Server"
|
||||
res = proxy.RequestName(name)
|
||||
assert res in {(1,), (2,)} # 1: got the name, 2: queued
|
||||
|
||||
has_owner, = proxy.NameHasOwner(name, _timeout=3)
|
||||
assert has_owner is True
|
||||
|
||||
def test_filter(session_conn):
|
||||
bus = Proxy(message_bus, session_conn)
|
||||
name = "io.gitlab.takluyver.jeepney.tests.blocking_test_filter"
|
||||
|
||||
match_rule = MatchRule(
|
||||
type="signal",
|
||||
sender=message_bus.bus_name,
|
||||
interface=message_bus.interface,
|
||||
member="NameOwnerChanged",
|
||||
path=message_bus.object_path,
|
||||
)
|
||||
match_rule.add_arg_condition(0, name)
|
||||
|
||||
# Ask the message bus to subscribe us to this signal
|
||||
bus.AddMatch(match_rule)
|
||||
|
||||
with session_conn.filter(match_rule) as matches:
|
||||
res, = bus.RequestName(name)
|
||||
assert res == 1 # 1: got the name
|
||||
|
||||
signal_msg = session_conn.recv_until_filtered(matches, timeout=2)
|
||||
|
||||
assert signal_msg.body == (name, '', session_conn.unique_name)
|
||||
|
||||
|
||||
def test_recv_fd(respond_with_fd):
|
||||
getfd_call = new_method_call(respond_with_fd, 'GetFD')
|
||||
with open_dbus_connection(bus='SESSION', enable_fds=True) as conn:
|
||||
reply = conn.send_and_get_reply(getfd_call, timeout=5)
|
||||
|
||||
assert reply.header.message_type is MessageType.method_return
|
||||
with reply.body[0].to_file('w+') as f:
|
||||
assert f.read() == 'readme'
|
||||
|
||||
|
||||
def test_send_fd(temp_file_and_contents, read_from_fd):
|
||||
temp_file, data = temp_file_and_contents
|
||||
readfd_call = new_method_call(read_from_fd, 'ReadFD', 'h', (temp_file,))
|
||||
with open_dbus_connection(bus='SESSION', enable_fds=True) as conn:
|
||||
reply = conn.send_and_get_reply(readfd_call, timeout=5)
|
||||
|
||||
assert reply.header.message_type is MessageType.method_return
|
||||
assert reply.body[0] == data
|
||||
83
lib/jeepney/io/tests/test_threading.py
Normal file
83
lib/jeepney/io/tests/test_threading.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
|
||||
from jeepney import new_method_call, MessageType, DBusAddress
|
||||
from jeepney.bus_messages import message_bus, MatchRule
|
||||
from jeepney.io.threading import open_dbus_router, Proxy
|
||||
from .utils import have_session_bus
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not have_session_bus, reason="Tests require DBus session bus"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def router():
|
||||
with open_dbus_router(bus='SESSION') as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def test_connect(router):
|
||||
assert router.unique_name.startswith(':')
|
||||
|
||||
bus_peer = DBusAddress(
|
||||
bus_name='org.freedesktop.DBus',
|
||||
object_path='/org/freedesktop/DBus',
|
||||
interface='org.freedesktop.DBus.Peer'
|
||||
)
|
||||
|
||||
def test_send_and_get_reply(router):
|
||||
ping_call = new_method_call(bus_peer, 'Ping')
|
||||
reply = router.send_and_get_reply(ping_call, timeout=5)
|
||||
assert reply.header.message_type == MessageType.method_return
|
||||
assert reply.body == ()
|
||||
|
||||
def test_proxy(router):
|
||||
proxy = Proxy(message_bus, router, timeout=5)
|
||||
name = "io.gitlab.takluyver.jeepney.examples.Server"
|
||||
res = proxy.RequestName(name)
|
||||
assert res in {(1,), (2,)} # 1: got the name, 2: queued
|
||||
|
||||
has_owner, = proxy.NameHasOwner(name, _timeout=3)
|
||||
assert has_owner is True
|
||||
|
||||
def test_filter(router):
|
||||
bus = Proxy(message_bus, router)
|
||||
name = "io.gitlab.takluyver.jeepney.tests.threading_test_filter"
|
||||
|
||||
match_rule = MatchRule(
|
||||
type="signal",
|
||||
sender=message_bus.bus_name,
|
||||
interface=message_bus.interface,
|
||||
member="NameOwnerChanged",
|
||||
path=message_bus.object_path,
|
||||
)
|
||||
match_rule.add_arg_condition(0, name)
|
||||
|
||||
# Ask the message bus to subscribe us to this signal
|
||||
bus.AddMatch(match_rule)
|
||||
|
||||
with router.filter(match_rule) as queue:
|
||||
res, = bus.RequestName(name)
|
||||
assert res == 1 # 1: got the name
|
||||
|
||||
signal_msg = queue.get(timeout=2.0)
|
||||
assert signal_msg.body == (name, '', router.unique_name)
|
||||
|
||||
|
||||
def test_recv_fd(respond_with_fd):
|
||||
getfd_call = new_method_call(respond_with_fd, 'GetFD')
|
||||
with open_dbus_router(bus='SESSION', enable_fds=True) as router:
|
||||
reply = router.send_and_get_reply(getfd_call, timeout=5)
|
||||
|
||||
assert reply.header.message_type is MessageType.method_return
|
||||
with reply.body[0].to_file('w+') as f:
|
||||
assert f.read() == 'readme'
|
||||
|
||||
|
||||
def test_send_fd(temp_file_and_contents, read_from_fd):
|
||||
temp_file, data = temp_file_and_contents
|
||||
readfd_call = new_method_call(read_from_fd, 'ReadFD', 'h', (temp_file,))
|
||||
with open_dbus_router(bus='SESSION', enable_fds=True) as router:
|
||||
reply = router.send_and_get_reply(readfd_call, timeout=5)
|
||||
|
||||
assert reply.header.message_type is MessageType.method_return
|
||||
assert reply.body[0] == data
|
||||
114
lib/jeepney/io/tests/test_trio.py
Normal file
114
lib/jeepney/io/tests/test_trio.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import trio
|
||||
import pytest
|
||||
|
||||
from jeepney import DBusAddress, DBusErrorResponse, MessageType, new_method_call
|
||||
from jeepney.bus_messages import message_bus, MatchRule
|
||||
from jeepney.io.trio import (
|
||||
open_dbus_connection, open_dbus_router, Proxy,
|
||||
)
|
||||
from .utils import have_session_bus
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.trio,
|
||||
pytest.mark.skipif(
|
||||
not have_session_bus, reason="Tests require DBus session bus"
|
||||
),
|
||||
]
|
||||
|
||||
# Can't use any async fixtures here, because pytest-asyncio tries to handle
|
||||
# all of them: https://github.com/pytest-dev/pytest-asyncio/issues/124
|
||||
|
||||
async def test_connect():
|
||||
conn = await open_dbus_connection(bus='SESSION')
|
||||
async with conn:
|
||||
assert conn.unique_name.startswith(':')
|
||||
|
||||
bus_peer = DBusAddress(
|
||||
bus_name='org.freedesktop.DBus',
|
||||
object_path='/org/freedesktop/DBus',
|
||||
interface='org.freedesktop.DBus.Peer'
|
||||
)
|
||||
|
||||
async def test_send_and_get_reply():
|
||||
ping_call = new_method_call(bus_peer, 'Ping')
|
||||
async with open_dbus_router(bus='SESSION') as req:
|
||||
with trio.fail_after(5):
|
||||
reply = await req.send_and_get_reply(ping_call)
|
||||
|
||||
assert reply.header.message_type == MessageType.method_return
|
||||
assert reply.body == ()
|
||||
|
||||
|
||||
async def test_send_and_get_reply_error():
|
||||
ping_call = new_method_call(bus_peer, 'Snart') # No such method
|
||||
async with open_dbus_router(bus='SESSION') as req:
|
||||
with trio.fail_after(5):
|
||||
reply = await req.send_and_get_reply(ping_call)
|
||||
|
||||
assert reply.header.message_type == MessageType.error
|
||||
|
||||
|
||||
async def test_proxy():
|
||||
async with open_dbus_router(bus='SESSION') as req:
|
||||
proxy = Proxy(message_bus, req)
|
||||
name = "io.gitlab.takluyver.jeepney.examples.Server"
|
||||
res = await proxy.RequestName(name)
|
||||
assert res in {(1,), (2,)} # 1: got the name, 2: queued
|
||||
|
||||
has_owner, = await proxy.NameHasOwner(name)
|
||||
assert has_owner is True
|
||||
|
||||
|
||||
async def test_proxy_error():
|
||||
async with open_dbus_router(bus='SESSION') as req:
|
||||
proxy = Proxy(message_bus, req)
|
||||
with pytest.raises(DBusErrorResponse):
|
||||
await proxy.RequestName(":123") # Invalid name
|
||||
|
||||
|
||||
async def test_filter():
|
||||
name = "io.gitlab.takluyver.jeepney.tests.trio_test_filter"
|
||||
async with open_dbus_router(bus='SESSION') as router:
|
||||
bus = Proxy(message_bus, router)
|
||||
|
||||
match_rule = MatchRule(
|
||||
type="signal",
|
||||
sender=message_bus.bus_name,
|
||||
interface=message_bus.interface,
|
||||
member="NameOwnerChanged",
|
||||
path=message_bus.object_path,
|
||||
)
|
||||
match_rule.add_arg_condition(0, name)
|
||||
|
||||
# Ask the message bus to subscribe us to this signal
|
||||
await bus.AddMatch(match_rule)
|
||||
|
||||
async with router.filter(match_rule) as chan:
|
||||
res, = await bus.RequestName(name)
|
||||
assert res == 1 # 1: got the name
|
||||
|
||||
with trio.fail_after(2.0):
|
||||
signal_msg = await chan.receive()
|
||||
assert signal_msg.body == (name, '', router.unique_name)
|
||||
|
||||
|
||||
async def test_recv_fd(respond_with_fd):
|
||||
getfd_call = new_method_call(respond_with_fd, 'GetFD')
|
||||
with trio.fail_after(5):
|
||||
async with open_dbus_router(bus='SESSION', enable_fds=True) as router:
|
||||
reply = await router.send_and_get_reply(getfd_call)
|
||||
|
||||
assert reply.header.message_type is MessageType.method_return
|
||||
with reply.body[0].to_file('w+') as f:
|
||||
assert f.read() == 'readme'
|
||||
|
||||
|
||||
async def test_send_fd(temp_file_and_contents, read_from_fd):
|
||||
temp_file, data = temp_file_and_contents
|
||||
readfd_call = new_method_call(read_from_fd, 'ReadFD', 'h', (temp_file,))
|
||||
with trio.fail_after(5):
|
||||
async with open_dbus_router(bus='SESSION', enable_fds=True) as router:
|
||||
reply = await router.send_and_get_reply(readfd_call)
|
||||
|
||||
assert reply.header.message_type is MessageType.method_return
|
||||
assert reply.body[0] == data
|
||||
3
lib/jeepney/io/tests/utils.py
Normal file
3
lib/jeepney/io/tests/utils.py
Normal file
@@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
have_session_bus = bool(os.environ.get('DBUS_SESSION_BUS_ADDRESS'))
|
||||
273
lib/jeepney/io/threading.py
Normal file
273
lib/jeepney/io/threading.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Synchronous IO wrappers with thread safety
|
||||
"""
|
||||
from concurrent.futures import Future
|
||||
from contextlib import contextmanager
|
||||
import functools
|
||||
import os
|
||||
from selectors import EVENT_READ
|
||||
import socket
|
||||
from queue import Queue, Full as QueueFull
|
||||
from threading import Lock, Thread
|
||||
from typing import Optional
|
||||
|
||||
from jeepney import Message, MessageType
|
||||
from jeepney.bus import get_bus
|
||||
from jeepney.bus_messages import message_bus
|
||||
from jeepney.wrappers import ProxyBase, unwrap_msg
|
||||
from .blocking import (
|
||||
unwrap_read, prep_socket, DBusConnectionBase, timeout_to_deadline,
|
||||
)
|
||||
from .common import (
|
||||
MessageFilters, FilterHandle, ReplyMatcher, RouterClosed, check_replyable,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'open_dbus_connection',
|
||||
'open_dbus_router',
|
||||
'DBusConnection',
|
||||
'DBusRouter',
|
||||
'Proxy',
|
||||
'ReceiveStopped',
|
||||
]
|
||||
|
||||
|
||||
class ReceiveStopped(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DBusConnection(DBusConnectionBase):
|
||||
def __init__(self, sock: socket.socket, enable_fds=False):
|
||||
super().__init__(sock, enable_fds=enable_fds)
|
||||
self._stop_r, self._stop_w = os.pipe()
|
||||
self.stop_key = self.selector.register(self._stop_r, EVENT_READ)
|
||||
self.send_lock = Lock()
|
||||
self.rcv_lock = Lock()
|
||||
|
||||
def send(self, message: Message, serial=None):
|
||||
"""Serialise and send a :class:`~.Message` object"""
|
||||
data, fds = self._serialise(message, serial)
|
||||
with self.send_lock:
|
||||
if fds:
|
||||
self._send_with_fds(data, fds)
|
||||
else:
|
||||
self.sock.sendall(data)
|
||||
|
||||
def receive(self, *, timeout=None) -> Message:
|
||||
"""Return the next available message from the connection
|
||||
|
||||
If the data is ready, this will return immediately, even if timeout<=0.
|
||||
Otherwise, it will wait for up to timeout seconds, or indefinitely if
|
||||
timeout is None. If no message comes in time, it raises TimeoutError.
|
||||
|
||||
If the connection is closed from another thread, this will raise
|
||||
ReceiveStopped.
|
||||
"""
|
||||
deadline = timeout_to_deadline(timeout)
|
||||
|
||||
if not self.rcv_lock.acquire(timeout=(timeout or -1)):
|
||||
raise TimeoutError(f"Did not get receive lock in {timeout} seconds")
|
||||
try:
|
||||
return self._receive(deadline)
|
||||
finally:
|
||||
self.rcv_lock.release()
|
||||
|
||||
def _read_some_data(self, timeout=None):
|
||||
# Wait for data or a signal on the stop pipe
|
||||
for key, ev in self.selector.select(timeout):
|
||||
if key == self.select_key:
|
||||
if self.enable_fds:
|
||||
return self._read_with_fds()
|
||||
else:
|
||||
return unwrap_read(self.sock.recv(4096)), []
|
||||
elif key == self.stop_key:
|
||||
raise ReceiveStopped("DBus receive stopped from another thread")
|
||||
|
||||
raise TimeoutError
|
||||
|
||||
def interrupt(self):
|
||||
"""Make any threads waiting for a message raise ReceiveStopped"""
|
||||
os.write(self._stop_w, b'a')
|
||||
|
||||
def reset_interrupt(self):
|
||||
"""Allow calls to .receive() again after .interrupt()
|
||||
|
||||
To avoid race conditions, you should typically wait for threads to
|
||||
respond (e.g. by joining them) between interrupting and resetting.
|
||||
"""
|
||||
# Clear any data on the stop pipe
|
||||
while (self.stop_key, EVENT_READ) in self.selector.select(timeout=0):
|
||||
os.read(self._stop_r, 1024)
|
||||
|
||||
def close(self):
|
||||
"""Close the connection"""
|
||||
self.interrupt()
|
||||
super().close()
|
||||
|
||||
|
||||
def open_dbus_connection(bus='SESSION', enable_fds=False, auth_timeout=1.):
|
||||
"""Open a plain D-Bus connection
|
||||
|
||||
D-Bus has an authentication step before sending or receiving messages.
|
||||
This takes < 1 ms in normal operation, but there is a timeout so that client
|
||||
code won't get stuck if the server doesn't reply. *auth_timeout* configures
|
||||
this timeout in seconds.
|
||||
|
||||
:return: :class:`DBusConnection`
|
||||
"""
|
||||
bus_addr = get_bus(bus)
|
||||
sock = prep_socket(bus_addr, enable_fds, timeout=auth_timeout)
|
||||
|
||||
conn = DBusConnection(sock, enable_fds)
|
||||
|
||||
with DBusRouter(conn) as router:
|
||||
reply_body = Proxy(message_bus, router, timeout=10).Hello()
|
||||
conn.unique_name = reply_body[0]
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
class DBusRouter:
|
||||
"""A client D-Bus connection which can wait for replies.
|
||||
|
||||
This runs a separate receiver thread and dispatches received messages.
|
||||
|
||||
It's possible to wrap a :class:`DBusConnection` in a router temporarily.
|
||||
Using the connection directly while it is wrapped is not supported,
|
||||
but you can use it again after the router is closed.
|
||||
"""
|
||||
def __init__(self, conn: DBusConnection):
|
||||
self.conn = conn
|
||||
self._replies = ReplyMatcher()
|
||||
self._filters = MessageFilters()
|
||||
self._rcv_thread = Thread(target=self._receiver, daemon=True)
|
||||
self._rcv_thread.start()
|
||||
|
||||
@property
|
||||
def unique_name(self):
|
||||
return self.conn.unique_name
|
||||
|
||||
def send(self, message, *, serial=None):
|
||||
"""Serialise and send a :class:`~.Message` object"""
|
||||
self.conn.send(message, serial=serial)
|
||||
|
||||
def send_and_get_reply(self, msg: Message, *, timeout=None) -> Message:
|
||||
"""Send a method call message, wait for and return a reply"""
|
||||
check_replyable(msg)
|
||||
if not self._rcv_thread.is_alive():
|
||||
raise RouterClosed("This D-Bus router has stopped")
|
||||
|
||||
serial = next(self.conn.outgoing_serial)
|
||||
|
||||
with self._replies.catch(serial, Future()) as reply_fut:
|
||||
self.conn.send(msg, serial=serial)
|
||||
return reply_fut.result(timeout=timeout)
|
||||
|
||||
def close(self):
|
||||
"""Close this router
|
||||
|
||||
This does not close the underlying connection.
|
||||
"""
|
||||
self.conn.interrupt()
|
||||
self._rcv_thread.join(timeout=10)
|
||||
self.conn.reset_interrupt()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def filter(self, rule, *, queue: Optional[Queue] =None, bufsize=1):
|
||||
"""Create a filter for incoming messages
|
||||
|
||||
Usage::
|
||||
|
||||
with router.filter(rule) as queue:
|
||||
matching_msg = queue.get()
|
||||
|
||||
:param jeepney.MatchRule rule: Catch messages matching this rule
|
||||
:param queue.Queue queue: Matched messages will be added to this
|
||||
:param int bufsize: If no queue is passed in, create one with this size
|
||||
"""
|
||||
return FilterHandle(self._filters, rule, queue or Queue(maxsize=bufsize))
|
||||
|
||||
# Code to run in receiver thread ------------------------------------
|
||||
|
||||
def _dispatch(self, msg: Message):
|
||||
if self._replies.dispatch(msg):
|
||||
return
|
||||
|
||||
for filter in self._filters.matches(msg):
|
||||
try:
|
||||
filter.queue.put_nowait(msg)
|
||||
except QueueFull:
|
||||
pass
|
||||
|
||||
def _receiver(self):
|
||||
try:
|
||||
while True:
|
||||
msg = self.conn.receive()
|
||||
self._dispatch(msg)
|
||||
except ReceiveStopped:
|
||||
pass
|
||||
finally:
|
||||
# Send errors to any tasks still waiting for a message.
|
||||
self._replies.drop_all()
|
||||
|
||||
class Proxy(ProxyBase):
|
||||
"""A blocking proxy for calling D-Bus methods via a :class:`DBusRouter`.
|
||||
|
||||
You can call methods on the proxy object, such as ``bus_proxy.Hello()``
|
||||
to make a method call over D-Bus and wait for a reply. It will either
|
||||
return a tuple of returned data, or raise :exc:`.DBusErrorResponse`.
|
||||
The methods available are defined by the message generator you wrap.
|
||||
|
||||
You can set a time limit on a call by passing ``_timeout=`` in the method
|
||||
call, or set a default when creating the proxy. The ``_timeout`` argument
|
||||
is not passed to the message generator.
|
||||
All timeouts are in seconds, and :exc:`TimeoutErrror` is raised if it
|
||||
expires before a reply arrives.
|
||||
|
||||
:param msggen: A message generator object
|
||||
:param ~threading.DBusRouter router: Router to send and receive messages
|
||||
:param float timeout: Default seconds to wait for a reply, or None for no limit
|
||||
"""
|
||||
def __init__(self, msggen, router, *, timeout=None):
|
||||
super().__init__(msggen)
|
||||
self._router = router
|
||||
self._timeout = timeout
|
||||
|
||||
def __repr__(self):
|
||||
extra = '' if (self._timeout is None) else f', timeout={self._timeout}'
|
||||
return f"Proxy({self._msggen}, {self._router}{extra})"
|
||||
|
||||
def _method_call(self, make_msg):
|
||||
@functools.wraps(make_msg)
|
||||
def inner(*args, **kwargs):
|
||||
timeout = kwargs.pop('_timeout', self._timeout)
|
||||
msg = make_msg(*args, **kwargs)
|
||||
assert msg.header.message_type is MessageType.method_call
|
||||
reply = self._router.send_and_get_reply(msg, timeout=timeout)
|
||||
return unwrap_msg(reply)
|
||||
|
||||
return inner
|
||||
|
||||
@contextmanager
|
||||
def open_dbus_router(bus='SESSION', enable_fds=False):
|
||||
"""Open a D-Bus 'router' to send and receive messages.
|
||||
|
||||
Use as a context manager::
|
||||
|
||||
with open_dbus_router() as router:
|
||||
...
|
||||
|
||||
On leaving the ``with`` block, the connection will be closed.
|
||||
|
||||
:param str bus: 'SESSION' or 'SYSTEM' or a supported address.
|
||||
:param bool enable_fds: Whether to enable passing file descriptors.
|
||||
:return: :class:`DBusRouter`
|
||||
"""
|
||||
with open_dbus_connection(bus=bus, enable_fds=enable_fds) as conn:
|
||||
with DBusRouter(conn) as router:
|
||||
yield router
|
||||
424
lib/jeepney/io/trio.py
Normal file
424
lib/jeepney/io/trio.py
Normal file
@@ -0,0 +1,424 @@
|
||||
import array
|
||||
import errno
|
||||
import logging
|
||||
import socket
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from itertools import count
|
||||
from typing import Optional
|
||||
|
||||
from outcome import Value, Error
|
||||
import trio
|
||||
from trio.abc import Channel
|
||||
|
||||
from jeepney.auth import Authenticator, BEGIN
|
||||
from jeepney.bus import get_bus
|
||||
from jeepney.fds import FileDescriptor, fds_buf_size
|
||||
from jeepney.low_level import Parser, MessageType, Message
|
||||
from jeepney.wrappers import ProxyBase, unwrap_msg
|
||||
from jeepney.bus_messages import message_bus
|
||||
from .common import (
|
||||
MessageFilters, FilterHandle, ReplyMatcher, RouterClosed, check_replyable,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'open_dbus_connection',
|
||||
'open_dbus_router',
|
||||
'Proxy',
|
||||
]
|
||||
|
||||
|
||||
# The function below is copied from trio, which is under the MIT license:
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
@contextmanager
|
||||
def _translate_socket_errors_to_stream_errors():
|
||||
try:
|
||||
yield
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EBADF, errno.ENOTSOCK}:
|
||||
# EBADF on Unix, ENOTSOCK on Windows
|
||||
raise trio.ClosedResourceError("this socket was already closed") from None
|
||||
else:
|
||||
raise trio.BrokenResourceError(
|
||||
"socket connection broken: {}".format(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
class DBusConnection(Channel):
|
||||
"""A plain D-Bus connection with no matching of replies.
|
||||
|
||||
This doesn't run any separate tasks: sending and receiving are done in
|
||||
the task that calls those methods. It's suitable for implementing servers:
|
||||
several worker tasks can receive requests and send replies.
|
||||
For a typical client pattern, see :class:`DBusRouter`.
|
||||
|
||||
Implements trio's channel interface for Message objects.
|
||||
"""
|
||||
def __init__(self, socket, enable_fds=False):
|
||||
self.socket = socket
|
||||
self.enable_fds = enable_fds
|
||||
self.parser = Parser()
|
||||
self.outgoing_serial = count(start=1)
|
||||
self.unique_name = None
|
||||
self.send_lock = trio.Lock()
|
||||
self.recv_lock = trio.Lock()
|
||||
self._leftover_to_send = None # type: Optional[memoryview]
|
||||
|
||||
async def send(self, message: Message, *, serial=None):
|
||||
"""Serialise and send a :class:`~.Message` object"""
|
||||
async with self.send_lock:
|
||||
if serial is None:
|
||||
serial = next(self.outgoing_serial)
|
||||
fds = array.array('i') if self.enable_fds else None
|
||||
data = message.serialise(serial, fds=fds)
|
||||
await self._send_data(data, fds)
|
||||
|
||||
# _send_data is copied & modified from trio's SocketStream.send_all() .
|
||||
# See above for the MIT license.
|
||||
async def _send_data(self, data: bytes, fds):
|
||||
if self.socket.did_shutdown_SHUT_WR:
|
||||
raise trio.ClosedResourceError("can't send data after sending EOF")
|
||||
|
||||
with _translate_socket_errors_to_stream_errors():
|
||||
if self._leftover_to_send:
|
||||
# A previous message was partly sent - finish sending it now.
|
||||
await self._send_remainder(self._leftover_to_send)
|
||||
|
||||
with memoryview(data) as data:
|
||||
if fds:
|
||||
sent = await self.socket.sendmsg([data], [(
|
||||
trio.socket.SOL_SOCKET, trio.socket.SCM_RIGHTS, fds
|
||||
)])
|
||||
else:
|
||||
sent = await self.socket.send(data)
|
||||
|
||||
await self._send_remainder(data, sent)
|
||||
|
||||
async def _send_remainder(self, data: memoryview, already_sent=0):
|
||||
try:
|
||||
while already_sent < len(data):
|
||||
with data[already_sent:] as remaining:
|
||||
sent = await self.socket.send(remaining)
|
||||
already_sent += sent
|
||||
self._leftover_to_send = None
|
||||
except trio.Cancelled:
|
||||
# Sending cancelled mid-message. Keep track of the remaining data
|
||||
# so it can be sent before the next message, otherwise the next
|
||||
# message won't be recognised.
|
||||
self._leftover_to_send = data[already_sent:]
|
||||
raise
|
||||
|
||||
async def receive(self) -> Message:
|
||||
"""Return the next available message from the connection"""
|
||||
async with self.recv_lock:
|
||||
while True:
|
||||
msg = self.parser.get_next_message()
|
||||
if msg is not None:
|
||||
return msg
|
||||
|
||||
# Once data is read, it must be given to the parser with no
|
||||
# checkpoints (where the task could be cancelled).
|
||||
b, fds = await self._read_data()
|
||||
if not b:
|
||||
raise trio.EndOfChannel("Socket closed at the other end")
|
||||
self.parser.add_data(b, fds)
|
||||
|
||||
async def _read_data(self):
|
||||
if self.enable_fds:
|
||||
nbytes = self.parser.bytes_desired()
|
||||
with _translate_socket_errors_to_stream_errors():
|
||||
data, ancdata, flags, _ = await self.socket.recvmsg(
|
||||
nbytes, fds_buf_size()
|
||||
)
|
||||
if flags & getattr(trio.socket, 'MSG_CTRUNC', 0):
|
||||
self._close()
|
||||
raise RuntimeError("Unable to receive all file descriptors")
|
||||
return data, FileDescriptor.from_ancdata(ancdata)
|
||||
|
||||
else: # not self.enable_fds
|
||||
with _translate_socket_errors_to_stream_errors():
|
||||
data = await self.socket.recv(4096)
|
||||
return data, []
|
||||
|
||||
def _close(self):
|
||||
self.socket.close()
|
||||
self._leftover_to_send = None
|
||||
|
||||
# Our closing is currently sync, but AsyncResource objects must have aclose
|
||||
async def aclose(self):
|
||||
"""Close the D-Bus connection"""
|
||||
self._close()
|
||||
|
||||
@asynccontextmanager
|
||||
async def router(self):
|
||||
"""Temporarily wrap this connection as a :class:`DBusRouter`
|
||||
|
||||
To be used like::
|
||||
|
||||
async with conn.router() as req:
|
||||
reply = await req.send_and_get_reply(msg)
|
||||
|
||||
While the router is running, you shouldn't use :meth:`receive`.
|
||||
Once the router is closed, you can use the plain connection again.
|
||||
"""
|
||||
async with trio.open_nursery() as nursery:
|
||||
router = DBusRouter(self)
|
||||
await router.start(nursery)
|
||||
try:
|
||||
yield router
|
||||
finally:
|
||||
await router.aclose()
|
||||
|
||||
|
||||
async def open_dbus_connection(bus='SESSION', *, enable_fds=False) -> DBusConnection:
|
||||
"""Open a plain D-Bus connection
|
||||
|
||||
:return: :class:`DBusConnection`
|
||||
"""
|
||||
bus_addr = get_bus(bus)
|
||||
sock : trio.SocketStream = await trio.open_unix_socket(bus_addr)
|
||||
|
||||
# Authentication
|
||||
authr = Authenticator(enable_fds=enable_fds, inc_null_byte=False)
|
||||
if hasattr(socket, 'SCM_CREDS'):
|
||||
# BSD: send credentials message to authenticate (kernel fills in data)
|
||||
await sock.socket.sendmsg(
|
||||
[b'\0'], [(socket.SOL_SOCKET, socket.SCM_CREDS, bytes(512))]
|
||||
)
|
||||
else:
|
||||
# Linux: no ancillary data needed, bus checks with SO_PEERCRED
|
||||
await sock.send_all(b'\0')
|
||||
for req_data in authr:
|
||||
await sock.send_all(req_data)
|
||||
authr.feed(await sock.receive_some())
|
||||
await sock.send_all(BEGIN)
|
||||
|
||||
conn = DBusConnection(sock.socket, enable_fds=enable_fds)
|
||||
|
||||
# Say *Hello* to the message bus - this must be the first message, and the
|
||||
# reply gives us our unique name.
|
||||
async with conn.router() as router:
|
||||
reply = await router.send_and_get_reply(message_bus.Hello())
|
||||
conn.unique_name = reply.body[0]
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
class TrioFilterHandle(FilterHandle):
|
||||
def __init__(self, filters: MessageFilters, rule, send_chn, recv_chn):
|
||||
super().__init__(filters, rule, recv_chn)
|
||||
self.send_channel = send_chn
|
||||
|
||||
@property
|
||||
def receive_channel(self):
|
||||
return self.queue
|
||||
|
||||
async def aclose(self):
|
||||
self.close()
|
||||
await self.send_channel.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.queue
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.aclose()
|
||||
|
||||
|
||||
class Future:
|
||||
"""A very simple Future for trio based on `trio.Event`."""
|
||||
def __init__(self):
|
||||
self._outcome = None
|
||||
self._event = trio.Event()
|
||||
|
||||
def set_result(self, result):
|
||||
self._outcome = Value(result)
|
||||
self._event.set()
|
||||
|
||||
def set_exception(self, exc):
|
||||
self._outcome = Error(exc)
|
||||
self._event.set()
|
||||
|
||||
async def get(self):
|
||||
await self._event.wait()
|
||||
return self._outcome.unwrap()
|
||||
|
||||
|
||||
class DBusRouter:
|
||||
"""A client D-Bus connection which can wait for replies.
|
||||
|
||||
This runs a separate receiver task and dispatches received messages.
|
||||
"""
|
||||
_nursery_mgr = None
|
||||
_rcv_cancel_scope = None
|
||||
|
||||
def __init__(self, conn: DBusConnection):
|
||||
self._conn = conn
|
||||
self._replies = ReplyMatcher()
|
||||
self._filters = MessageFilters()
|
||||
|
||||
@property
|
||||
def unique_name(self):
|
||||
return self._conn.unique_name
|
||||
|
||||
async def send(self, message, *, serial=None):
|
||||
"""Send a message, don't wait for a reply
|
||||
"""
|
||||
await self._conn.send(message, serial=serial)
|
||||
|
||||
async def send_and_get_reply(self, message) -> Message:
|
||||
"""Send a method call message and wait for the reply
|
||||
|
||||
Returns the reply message (method return or error message type).
|
||||
"""
|
||||
check_replyable(message)
|
||||
if self._rcv_cancel_scope is None:
|
||||
raise RouterClosed("This DBusRouter has stopped")
|
||||
|
||||
serial = next(self._conn.outgoing_serial)
|
||||
|
||||
with self._replies.catch(serial, Future()) as reply_fut:
|
||||
await self.send(message, serial=serial)
|
||||
return (await reply_fut.get())
|
||||
|
||||
def filter(self, rule, *, channel: Optional[trio.MemorySendChannel]=None, bufsize=1):
|
||||
"""Create a filter for incoming messages
|
||||
|
||||
Usage::
|
||||
|
||||
async with router.filter(rule) as receive_channel:
|
||||
matching_msg = await receive_channel.receive()
|
||||
|
||||
# OR:
|
||||
send_chan, recv_chan = trio.open_memory_channel(1)
|
||||
async with router.filter(rule, channel=send_chan):
|
||||
matching_msg = await recv_chan.receive()
|
||||
|
||||
If the channel fills up,
|
||||
The sending end of the channel is closed when leaving the ``async with``
|
||||
block, whether or not it was passed in.
|
||||
|
||||
:param jeepney.MatchRule rule: Catch messages matching this rule
|
||||
:param trio.MemorySendChannel channel: Send matching messages here
|
||||
:param int bufsize: If no channel is passed in, create one with this size
|
||||
"""
|
||||
if channel is None:
|
||||
channel, recv_channel = trio.open_memory_channel(bufsize)
|
||||
else:
|
||||
recv_channel = None
|
||||
return TrioFilterHandle(self._filters, rule, channel, recv_channel)
|
||||
|
||||
# Task management -------------------------------------------
|
||||
|
||||
async def start(self, nursery: trio.Nursery):
|
||||
if self._rcv_cancel_scope is not None:
|
||||
raise RuntimeError("DBusRouter receiver task is already running")
|
||||
self._rcv_cancel_scope = await nursery.start(self._receiver)
|
||||
|
||||
async def aclose(self):
|
||||
"""Stop the sender & receiver tasks"""
|
||||
# It doesn't matter if we receive a partial message - the connection
|
||||
# should ensure that whatever is received is fed to the parser.
|
||||
if self._rcv_cancel_scope is not None:
|
||||
self._rcv_cancel_scope.cancel()
|
||||
self._rcv_cancel_scope = None
|
||||
|
||||
# Ensure trio checkpoint
|
||||
await trio.sleep(0)
|
||||
|
||||
# Code to run in receiver task ------------------------------------
|
||||
|
||||
def _dispatch(self, msg: Message):
|
||||
"""Handle one received message"""
|
||||
if self._replies.dispatch(msg):
|
||||
return
|
||||
|
||||
for filter in self._filters.matches(msg):
|
||||
try:
|
||||
filter.send_channel.send_nowait(msg)
|
||||
except trio.WouldBlock:
|
||||
pass
|
||||
|
||||
async def _receiver(self, task_status=trio.TASK_STATUS_IGNORED):
|
||||
"""Receiver loop - runs in a separate task"""
|
||||
with trio.CancelScope() as cscope:
|
||||
self.is_running = True
|
||||
task_status.started(cscope)
|
||||
try:
|
||||
while True:
|
||||
msg = await self._conn.receive()
|
||||
self._dispatch(msg)
|
||||
finally:
|
||||
self.is_running = False
|
||||
# Send errors to any tasks still waiting for a message.
|
||||
self._replies.drop_all()
|
||||
|
||||
# Closing a memory channel can't block, but it only has an
|
||||
# async close method, so we need to shield it from cancellation.
|
||||
with trio.move_on_after(3) as cleanup_scope:
|
||||
for filter in self._filters.filters.values():
|
||||
cleanup_scope.shield = True
|
||||
await filter.send_channel.aclose()
|
||||
|
||||
|
||||
class Proxy(ProxyBase):
|
||||
"""A trio proxy for calling D-Bus methods
|
||||
|
||||
You can call methods on the proxy object, such as ``await bus_proxy.Hello()``
|
||||
to make a method call over D-Bus and wait for a reply. It will either
|
||||
return a tuple of returned data, or raise :exc:`.DBusErrorResponse`.
|
||||
The methods available are defined by the message generator you wrap.
|
||||
|
||||
:param msggen: A message generator object.
|
||||
:param ~trio.DBusRouter router: Router to send and receive messages.
|
||||
"""
|
||||
def __init__(self, msggen, router):
|
||||
super().__init__(msggen)
|
||||
if not isinstance(router, DBusRouter):
|
||||
raise TypeError("Proxy can only be used with DBusRequester")
|
||||
self._router = router
|
||||
|
||||
def _method_call(self, make_msg):
|
||||
async def inner(*args, **kwargs):
|
||||
msg = make_msg(*args, **kwargs)
|
||||
assert msg.header.message_type is MessageType.method_call
|
||||
reply = await self._router.send_and_get_reply(msg)
|
||||
return unwrap_msg(reply)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_dbus_router(bus='SESSION', *, enable_fds=False):
|
||||
"""Open a D-Bus 'router' to send and receive messages.
|
||||
|
||||
Use as an async context manager::
|
||||
|
||||
async with open_dbus_router() as req:
|
||||
...
|
||||
|
||||
:param str bus: 'SESSION' or 'SYSTEM' or a supported address.
|
||||
:return: :class:`DBusRouter`
|
||||
|
||||
This is a shortcut for::
|
||||
|
||||
conn = await open_dbus_connection()
|
||||
async with conn:
|
||||
async with conn.router() as req:
|
||||
...
|
||||
"""
|
||||
conn = await open_dbus_connection(bus, enable_fds=enable_fds)
|
||||
async with conn:
|
||||
async with conn.router() as rtr:
|
||||
yield rtr
|
||||
608
lib/jeepney/low_level.py
Normal file
608
lib/jeepney/low_level.py
Normal file
@@ -0,0 +1,608 @@
|
||||
import string
|
||||
import struct
|
||||
from collections import deque
|
||||
from enum import Enum, IntEnum, IntFlag
|
||||
from typing import Optional
|
||||
|
||||
class SizeLimitError(ValueError):
|
||||
"""Raised when trying to (de-)serialise data exceeding D-Bus' size limit.
|
||||
|
||||
This is currently only implemented for arrays, where the maximum size is
|
||||
64 MiB.
|
||||
"""
|
||||
pass
|
||||
|
||||
class Endianness(Enum):
|
||||
little = 1
|
||||
big = 2
|
||||
|
||||
def struct_code(self):
|
||||
return '<' if (self is Endianness.little) else '>'
|
||||
|
||||
def dbus_code(self):
|
||||
return b'l' if (self is Endianness.little) else b'B'
|
||||
|
||||
|
||||
endian_map = {b'l': Endianness.little, b'B': Endianness.big}
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
method_call = 1
|
||||
method_return = 2
|
||||
error = 3
|
||||
signal = 4
|
||||
|
||||
|
||||
class MessageFlag(IntFlag):
|
||||
no_reply_expected = 1
|
||||
no_auto_start = 2
|
||||
allow_interactive_authorization = 4
|
||||
|
||||
|
||||
class HeaderFields(IntEnum):
|
||||
path = 1
|
||||
interface = 2
|
||||
member = 3
|
||||
error_name = 4
|
||||
reply_serial = 5
|
||||
destination = 6
|
||||
sender = 7
|
||||
signature = 8
|
||||
unix_fds = 9
|
||||
|
||||
|
||||
def padding(pos, step):
|
||||
pad = step - (pos % step)
|
||||
if pad == step:
|
||||
return 0
|
||||
return pad
|
||||
|
||||
|
||||
class FixedType:
|
||||
def __init__(self, size, struct_code):
|
||||
self.size = self.alignment = size
|
||||
self.struct_code = struct_code
|
||||
|
||||
def parse_data(self, buf, pos, endianness, fds=()):
|
||||
pos += padding(pos, self.alignment)
|
||||
code = endianness.struct_code() + self.struct_code
|
||||
val = struct.unpack(code, buf[pos:pos + self.size])[0]
|
||||
return val, pos + self.size
|
||||
|
||||
def serialise(self, data, pos, endianness, fds=None):
|
||||
pad = b'\0' * padding(pos, self.alignment)
|
||||
code = endianness.struct_code() + self.struct_code
|
||||
return pad + struct.pack(code, data)
|
||||
|
||||
def __repr__(self):
|
||||
return 'FixedType({!r}, {!r})'.format(self.size, self.struct_code)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (type(other) is FixedType) and (self.size == other.size) \
|
||||
and (self.struct_code == other.struct_code)
|
||||
|
||||
|
||||
class Boolean(FixedType):
|
||||
def __init__(self):
|
||||
super().__init__(4, 'I') # D-Bus booleans take 4 bytes
|
||||
|
||||
def parse_data(self, buf, pos, endianness, fds=()):
|
||||
val, new_pos = super().parse_data(buf, pos, endianness)
|
||||
return bool(val), new_pos
|
||||
|
||||
def __repr__(self):
|
||||
return 'Boolean()'
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(other) is Boolean
|
||||
|
||||
|
||||
class FileDescriptor(FixedType):
|
||||
def __init__(self):
|
||||
super().__init__(4, 'I')
|
||||
|
||||
def parse_data(self, buf, pos, endianness, fds=()):
|
||||
idx, new_pos = super().parse_data(buf, pos, endianness)
|
||||
return fds[idx], new_pos
|
||||
|
||||
def serialise(self, data, pos, endianness, fds=None):
|
||||
if fds is None:
|
||||
raise RuntimeError("Sending FDs is not supported or not enabled")
|
||||
|
||||
if hasattr(data, 'fileno'):
|
||||
data = data.fileno()
|
||||
if isinstance(data, bool) or not isinstance(data, int):
|
||||
raise TypeError("Cannot use {data!r} as file descriptor. Expected "
|
||||
"an int or an object with fileno() method")
|
||||
|
||||
if data < 0:
|
||||
raise ValueError(f"File descriptor can't be negative ({data})")
|
||||
|
||||
fds.append(data)
|
||||
return super().serialise(len(fds) - 1, pos, endianness)
|
||||
|
||||
def __repr__(self):
|
||||
return 'FileDescriptor()'
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(other) is FileDescriptor
|
||||
|
||||
|
||||
simple_types = {
|
||||
'y': FixedType(1, 'B'), # unsigned 8 bit
|
||||
'n': FixedType(2, 'h'), # signed 16 bit
|
||||
'q': FixedType(2, 'H'), # unsigned 16 bit
|
||||
'b': Boolean(), # bool (32-bit)
|
||||
'i': FixedType(4, 'i'), # signed 32-bit
|
||||
'u': FixedType(4, 'I'), # unsigned 32-bit
|
||||
'x': FixedType(8, 'q'), # signed 64-bit
|
||||
't': FixedType(8, 'Q'), # unsigned 64-bit
|
||||
'd': FixedType(8, 'd'), # double
|
||||
'h': FileDescriptor(), # file descriptor (uint32 index in a separate list)
|
||||
}
|
||||
|
||||
|
||||
class StringType:
|
||||
def __init__(self, length_type):
|
||||
self.length_type = length_type
|
||||
|
||||
@property
|
||||
def alignment(self):
|
||||
return self.length_type.size
|
||||
|
||||
def parse_data(self, buf, pos, endianness, fds=()):
|
||||
length, pos = self.length_type.parse_data(buf, pos, endianness)
|
||||
end = pos + length
|
||||
val = buf[pos:end].decode('utf-8')
|
||||
assert buf[end:end + 1] == b'\0'
|
||||
return val, end + 1
|
||||
|
||||
def check_data(self, data):
|
||||
if not isinstance(data, str):
|
||||
raise TypeError("Expected str, not {!r}".format(data))
|
||||
|
||||
def serialise(self, data, pos, endianness, fds=None):
|
||||
self.check_data(data)
|
||||
encoded = data.encode('utf-8')
|
||||
len_data = self.length_type.serialise(len(encoded), pos, endianness)
|
||||
return len_data + encoded + b'\0'
|
||||
|
||||
def __repr__(self):
|
||||
return 'StringType({!r})'.format(self.length_type)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (type(other) is StringType) \
|
||||
and (self.length_type == other.length_type)
|
||||
|
||||
|
||||
class ObjectPathType(StringType):
|
||||
def __init__(self):
|
||||
super().__init__(simple_types['u'])
|
||||
|
||||
def check_data(self, data):
|
||||
super().check_data(data)
|
||||
if not data.startswith('/'):
|
||||
raise ValueError(f"Object path ({data!r}) must start with /")
|
||||
if data.endswith('/') and len(data) > 1:
|
||||
raise ValueError(f"Object path ({data!r}) cannot end with /")
|
||||
if '//' in data:
|
||||
raise ValueError(f"Object path ({data!r}) cannot contain double /")
|
||||
valid_chars = string.ascii_letters + string.digits + '/_'
|
||||
if any(c not in valid_chars for c in data):
|
||||
raise ValueError(
|
||||
f"Object path ({data!r}) can only contain A-Z, a-z, 0-9, / and _"
|
||||
)
|
||||
|
||||
|
||||
simple_types.update({
|
||||
's': StringType(simple_types['u']), # String
|
||||
'o': ObjectPathType(), # Object path
|
||||
'g': StringType(simple_types['y']), # Signature
|
||||
})
|
||||
|
||||
|
||||
class Struct:
|
||||
alignment = 8
|
||||
|
||||
def __init__(self, fields):
|
||||
if any(isinstance(f, DictEntry) for f in fields):
|
||||
raise TypeError("Found dict entry outside array")
|
||||
self.fields = fields
|
||||
|
||||
def parse_data(self, buf, pos, endianness, fds=()):
|
||||
pos += padding(pos, 8)
|
||||
res = []
|
||||
for field in self.fields:
|
||||
v, pos = field.parse_data(buf, pos, endianness, fds=fds)
|
||||
res.append(v)
|
||||
return tuple(res), pos
|
||||
|
||||
def serialise(self, data, pos, endianness, fds=None):
|
||||
if not isinstance(data, tuple):
|
||||
raise TypeError("Expected tuple, not {!r}".format(data))
|
||||
if len(data) != len(self.fields):
|
||||
raise ValueError("{} entries for {} fields".format(
|
||||
len(data), len(self.fields)
|
||||
))
|
||||
pad = b'\0' * padding(pos, self.alignment)
|
||||
pos += len(pad)
|
||||
res_pieces = []
|
||||
for item, field in zip(data, self.fields):
|
||||
res_pieces.append(field.serialise(item, pos, endianness, fds=fds))
|
||||
pos += len(res_pieces[-1])
|
||||
return pad + b''.join(res_pieces)
|
||||
|
||||
def __repr__(self):
|
||||
return "{}({!r})".format(type(self).__name__, self.fields)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (type(other) is type(self)) and (self.fields == other.fields)
|
||||
|
||||
|
||||
class DictEntry(Struct):
|
||||
def __init__(self, fields):
|
||||
if len(fields) != 2:
|
||||
raise TypeError(
|
||||
"Dict entry must have 2 fields, not %d" % len(fields))
|
||||
if not isinstance(fields[0], (FixedType, StringType)):
|
||||
raise TypeError(
|
||||
"First field in dict entry must be simple type, not {}"
|
||||
.format(type(fields[0])))
|
||||
super().__init__(fields)
|
||||
|
||||
class Array:
|
||||
alignment = 4
|
||||
length_type = FixedType(4, 'I')
|
||||
|
||||
def __init__(self, elt_type):
|
||||
self.elt_type = elt_type
|
||||
|
||||
def parse_data(self, buf, pos, endianness, fds=()):
|
||||
# print('Array start', pos)
|
||||
length, pos = self.length_type.parse_data(buf, pos, endianness)
|
||||
pos += padding(pos, self.elt_type.alignment)
|
||||
end = pos + length
|
||||
if self.elt_type == simple_types['y']: # Array of bytes
|
||||
return buf[pos:end], end
|
||||
|
||||
res = []
|
||||
while pos < end:
|
||||
# print('Array elem', pos)
|
||||
v, pos = self.elt_type.parse_data(buf, pos, endianness, fds=fds)
|
||||
res.append(v)
|
||||
if isinstance(self.elt_type, DictEntry):
|
||||
# Convert list of 2-tuples to dict
|
||||
res = dict(res)
|
||||
return res, pos
|
||||
|
||||
def serialise(self, data, pos, endianness, fds=None):
|
||||
data_is_bytes = False
|
||||
if isinstance(self.elt_type, DictEntry) and isinstance(data, dict):
|
||||
data = data.items()
|
||||
elif (self.elt_type == simple_types['y']) and isinstance(data, bytes):
|
||||
data_is_bytes = True
|
||||
elif not isinstance(data, list):
|
||||
raise TypeError("Not suitable for array: {!r}".format(data))
|
||||
|
||||
# Fail fast if we know in advance that the data is too big:
|
||||
if isinstance(self.elt_type, FixedType):
|
||||
if (self.elt_type.size * len(data)) > 2**26:
|
||||
raise SizeLimitError("Array size exceeds 64 MiB limit")
|
||||
|
||||
pad1 = padding(pos, self.alignment)
|
||||
pos_after_length = pos + pad1 + 4
|
||||
pad2 = padding(pos_after_length, self.elt_type.alignment)
|
||||
|
||||
if data_is_bytes:
|
||||
buf = data
|
||||
else:
|
||||
data_pos = pos_after_length + pad2
|
||||
limit_pos = data_pos + 2 ** 26
|
||||
chunks = []
|
||||
for item in data:
|
||||
chunks.append(self.elt_type.serialise(
|
||||
item, data_pos, endianness, fds=fds
|
||||
))
|
||||
data_pos += len(chunks[-1])
|
||||
if data_pos > limit_pos:
|
||||
raise SizeLimitError("Array size exceeds 64 MiB limit")
|
||||
buf = b''.join(chunks)
|
||||
|
||||
len_data = self.length_type.serialise(len(buf), pos+pad1, endianness)
|
||||
# print('Array ser: pad1={!r}, len_data={!r}, pad2={!r}, buf={!r}'.format(
|
||||
# pad1, len_data, pad2, buf))
|
||||
return (b'\0' * pad1) + len_data + (b'\0' * pad2) + buf
|
||||
|
||||
def __repr__(self):
|
||||
return 'Array({!r})'.format(self.elt_type)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (type(other) is Array) and (self.elt_type == other.elt_type)
|
||||
|
||||
|
||||
class Variant:
|
||||
alignment = 1
|
||||
|
||||
def parse_data(self, buf, pos, endianness, fds=()):
|
||||
# print('variant', pos)
|
||||
sig, pos = simple_types['g'].parse_data(buf, pos, endianness)
|
||||
# print('variant sig:', repr(sig), pos)
|
||||
valtype = parse_signature(list(sig))
|
||||
val, pos = valtype.parse_data(buf, pos, endianness, fds=fds)
|
||||
# print('variant done', (sig, val), pos)
|
||||
return (sig, val), pos
|
||||
|
||||
def serialise(self, data, pos, endianness, fds=None):
|
||||
sig, data = data
|
||||
valtype = parse_signature(list(sig))
|
||||
sig_buf = simple_types['g'].serialise(sig, pos, endianness)
|
||||
return sig_buf + valtype.serialise(
|
||||
data, pos + len(sig_buf), endianness, fds=fds
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return 'Variant()'
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(other) is Variant
|
||||
|
||||
def parse_signature(sig):
|
||||
"""Parse a symbolic signature into objects.
|
||||
"""
|
||||
# Based on http://norvig.com/lispy.html
|
||||
token = sig.pop(0)
|
||||
if token == 'a':
|
||||
return Array(parse_signature(sig))
|
||||
if token == 'v':
|
||||
return Variant()
|
||||
elif token == '(':
|
||||
fields = []
|
||||
while sig[0] != ')':
|
||||
fields.append(parse_signature(sig))
|
||||
sig.pop(0) # )
|
||||
return Struct(fields)
|
||||
elif token == '{':
|
||||
de = []
|
||||
while sig[0] != '}':
|
||||
de.append(parse_signature(sig))
|
||||
sig.pop(0) # }
|
||||
return DictEntry(de)
|
||||
elif token in ')}':
|
||||
raise ValueError('Unexpected end of struct')
|
||||
else:
|
||||
return simple_types[token]
|
||||
|
||||
|
||||
def calc_msg_size(buf):
|
||||
endian, = struct.unpack('c', buf[:1])
|
||||
endian = endian_map[endian]
|
||||
body_length, = struct.unpack(endian.struct_code() + 'I', buf[4:8])
|
||||
fields_array_len, = struct.unpack(endian.struct_code() + 'I', buf[12:16])
|
||||
header_len = 16 + fields_array_len
|
||||
return header_len + padding(header_len, 8) + body_length
|
||||
|
||||
|
||||
_header_fields_type = Array(Struct([simple_types['y'], Variant()]))
|
||||
|
||||
|
||||
def parse_header_fields(buf, endianness):
|
||||
l, pos = _header_fields_type.parse_data(buf, 12, endianness)
|
||||
return {HeaderFields(k): v[1] for (k, v) in l}, pos
|
||||
|
||||
|
||||
header_field_codes = {
|
||||
1: 'o',
|
||||
2: 's',
|
||||
3: 's',
|
||||
4: 's',
|
||||
5: 'u',
|
||||
6: 's',
|
||||
7: 's',
|
||||
8: 'g',
|
||||
9: 'u',
|
||||
}
|
||||
|
||||
|
||||
def serialise_header_fields(d, endianness):
|
||||
l = [(i.value, (header_field_codes[i], v)) for (i, v) in sorted(d.items())]
|
||||
return _header_fields_type.serialise(l, 12, endianness)
|
||||
|
||||
|
||||
class Header:
|
||||
def __init__(self, endianness, message_type, flags, protocol_version,
|
||||
body_length, serial, fields):
|
||||
"""A D-Bus message header
|
||||
|
||||
It's not normally necessary to construct this directly: use higher level
|
||||
functions and methods instead.
|
||||
"""
|
||||
self.endianness = endianness
|
||||
self.message_type = MessageType(message_type)
|
||||
self.flags = MessageFlag(flags)
|
||||
self.protocol_version = protocol_version
|
||||
self.body_length = body_length
|
||||
self.serial = serial
|
||||
self.fields = fields
|
||||
|
||||
def __repr__(self):
|
||||
return 'Header({!r}, {!r}, {!r}, {!r}, {!r}, {!r}, fields={!r})'.format(
|
||||
self.endianness, self.message_type, self.flags,
|
||||
self.protocol_version, self.body_length, self.serial, self.fields)
|
||||
|
||||
def serialise(self, serial=None):
|
||||
s = self.endianness.struct_code() + 'cBBBII'
|
||||
if serial is None:
|
||||
serial = self.serial
|
||||
return struct.pack(s, self.endianness.dbus_code(),
|
||||
self.message_type.value, self.flags,
|
||||
self.protocol_version,
|
||||
self.body_length, serial) \
|
||||
+ serialise_header_fields(self.fields, self.endianness)
|
||||
|
||||
@classmethod
|
||||
def from_buffer(cls, buf):
|
||||
endian, msgtype, flags, pv = struct.unpack('cBBB', buf[:4])
|
||||
endian = endian_map[endian]
|
||||
bodylen, serial = struct.unpack(endian.struct_code() + 'II', buf[4:12])
|
||||
fields, pos = parse_header_fields(buf, endian)
|
||||
return cls(endian, msgtype, flags, pv, bodylen, serial, fields), pos
|
||||
|
||||
|
||||
class Message:
|
||||
"""Object representing a DBus message.
|
||||
|
||||
It's not normally necessary to construct this directly: use higher level
|
||||
functions and methods instead.
|
||||
"""
|
||||
def __init__(self, header, body):
|
||||
self.header = header
|
||||
self.body = body
|
||||
|
||||
def __repr__(self):
|
||||
return "{}({!r}, {!r})".format(type(self).__name__, self.header, self.body)
|
||||
|
||||
@classmethod
|
||||
def from_buffer(cls, buf: bytes, fds=()) -> 'Message':
|
||||
header, pos = Header.from_buffer(buf)
|
||||
n_fds = header.fields.get(HeaderFields.unix_fds, 0)
|
||||
if n_fds > len(fds):
|
||||
raise ValueError(
|
||||
f"Message expects {n_fds} FDs, but only {len(fds)} were received"
|
||||
)
|
||||
fds = fds[:n_fds]
|
||||
body = ()
|
||||
if HeaderFields.signature in header.fields:
|
||||
sig = header.fields[HeaderFields.signature]
|
||||
body_type = parse_signature(list('(%s)' % sig))
|
||||
body = body_type.parse_data(buf, pos, header.endianness, fds=fds)[0]
|
||||
return Message(header, body)
|
||||
|
||||
def serialise(self, serial=None, fds=None) -> bytes:
|
||||
"""Convert this message to bytes.
|
||||
|
||||
Specifying *serial* overrides the ``msg.header.serial`` field, so a
|
||||
connection can use its own serial number without modifying the message.
|
||||
|
||||
If file-descriptor support is in use, *fds* should be a
|
||||
:class:`array.array` object with type ``'i'``. Any file descriptors in
|
||||
the message will be added to the array. If the message contains FDs,
|
||||
it can't be serialised without this array.
|
||||
"""
|
||||
endian = self.header.endianness
|
||||
|
||||
if HeaderFields.signature in self.header.fields:
|
||||
sig = self.header.fields[HeaderFields.signature]
|
||||
body_type = parse_signature(list('(%s)' % sig))
|
||||
body_buf = body_type.serialise(self.body, 0, endian, fds=fds)
|
||||
else:
|
||||
body_buf = b''
|
||||
|
||||
self.header.body_length = len(body_buf)
|
||||
if fds:
|
||||
self.header.fields[HeaderFields.unix_fds] = len(fds)
|
||||
|
||||
header_buf = self.header.serialise(serial=serial)
|
||||
pad = b'\0' * padding(len(header_buf), 8)
|
||||
return header_buf + pad + body_buf
|
||||
|
||||
|
||||
class Parser:
|
||||
"""Parse DBus messages from a stream of incoming data.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.buf = BufferPipe()
|
||||
self.fds = []
|
||||
self.next_msg_size = None
|
||||
|
||||
def add_data(self, data: bytes, fds=()):
|
||||
"""Provide newly received data to the parser"""
|
||||
self.buf.write(data)
|
||||
self.fds.extend(fds)
|
||||
|
||||
def feed(self, data):
|
||||
"""Feed the parser newly read data.
|
||||
|
||||
Returns a list of messages completed by the new data.
|
||||
"""
|
||||
self.add_data(data)
|
||||
return list(iter(self.get_next_message, None))
|
||||
|
||||
def bytes_desired(self):
|
||||
"""How many bytes can be received without going beyond the next message?
|
||||
|
||||
This is only used with file-descriptor passing, so we don't get too many
|
||||
FDs in a single recvmsg call.
|
||||
"""
|
||||
got = self.buf.bytes_buffered
|
||||
if got < 16: # The first 16 bytes tell us the message size
|
||||
return 16 - got
|
||||
|
||||
if self.next_msg_size is None:
|
||||
self.next_msg_size = calc_msg_size(self.buf.peek(16))
|
||||
return self.next_msg_size - got
|
||||
|
||||
def get_next_message(self) -> Optional[Message]:
|
||||
"""Parse one message, if there is enough data.
|
||||
|
||||
Returns None if it doesn't have a complete message.
|
||||
"""
|
||||
if self.next_msg_size is None:
|
||||
if self.buf.bytes_buffered >= 16:
|
||||
self.next_msg_size = calc_msg_size(self.buf.peek(16))
|
||||
nms = self.next_msg_size
|
||||
if (nms is not None) and self.buf.bytes_buffered >= nms:
|
||||
raw_msg = self.buf.read(nms)
|
||||
msg = Message.from_buffer(raw_msg, fds=self.fds)
|
||||
self.next_msg_size = None
|
||||
fds_consumed = msg.header.fields.get(HeaderFields.unix_fds, 0)
|
||||
self.fds = self.fds[fds_consumed:]
|
||||
return msg
|
||||
|
||||
|
||||
class BufferPipe:
|
||||
"""A place to store received data until we can parse a complete message
|
||||
|
||||
The main difference from io.BytesIO is that read & write operate at
|
||||
opposite ends, like a pipe.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.chunks = deque()
|
||||
self.bytes_buffered = 0
|
||||
|
||||
def write(self, b: bytes):
|
||||
self.chunks.append(b)
|
||||
self.bytes_buffered += len(b)
|
||||
|
||||
def _peek_iter(self, nbytes: int):
|
||||
assert nbytes <= self.bytes_buffered
|
||||
for chunk in self.chunks:
|
||||
chunk = chunk[:nbytes]
|
||||
nbytes -= len(chunk)
|
||||
yield chunk
|
||||
if nbytes <= 0:
|
||||
break
|
||||
|
||||
def peek(self, nbytes: int) -> bytes:
|
||||
"""Get exactly nbytes bytes from the front without removing them"""
|
||||
return b''.join(self._peek_iter(nbytes))
|
||||
|
||||
def _read_iter(self, nbytes: int):
|
||||
assert nbytes <= self.bytes_buffered
|
||||
while True:
|
||||
chunk = self.chunks.popleft()
|
||||
self.bytes_buffered -= len(chunk)
|
||||
if nbytes <= len(chunk):
|
||||
break
|
||||
nbytes -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
# Final chunk
|
||||
chunk, rem = chunk[:nbytes], chunk[nbytes:]
|
||||
if rem:
|
||||
self.chunks.appendleft(rem)
|
||||
self.bytes_buffered += len(rem)
|
||||
yield chunk
|
||||
|
||||
def read(self, nbytes: int) -> bytes:
|
||||
"""Take & return exactly nbytes bytes from the front"""
|
||||
return b''.join(self._read_iter(nbytes))
|
||||
0
lib/jeepney/tests/__init__.py
Normal file
0
lib/jeepney/tests/__init__.py
Normal file
BIN
lib/jeepney/tests/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/tests/__pycache__/test_auth.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/test_auth.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/tests/__pycache__/test_bindgen.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/test_bindgen.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/tests/__pycache__/test_bus.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/test_bus.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/tests/__pycache__/test_bus_messages.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/test_bus_messages.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/tests/__pycache__/test_fds.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/test_fds.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/tests/__pycache__/test_low_level.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/test_low_level.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lib/jeepney/tests/__pycache__/test_wrappers.cpython-314.pyc
Normal file
BIN
lib/jeepney/tests/__pycache__/test_wrappers.cpython-314.pyc
Normal file
Binary file not shown.
116
lib/jeepney/tests/secrets_introspect.xml
Normal file
116
lib/jeepney/tests/secrets_introspect.xml
Normal file
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
|
||||
<!-- GDBus 2.50.3 -->
|
||||
<node>
|
||||
<interface name="org.freedesktop.DBus.Properties">
|
||||
<method name="Get">
|
||||
<arg type="s" name="interface_name" direction="in"/>
|
||||
<arg type="s" name="property_name" direction="in"/>
|
||||
<arg type="v" name="value" direction="out"/>
|
||||
</method>
|
||||
<method name="GetAll">
|
||||
<arg type="s" name="interface_name" direction="in"/>
|
||||
<arg type="a{sv}" name="properties" direction="out"/>
|
||||
</method>
|
||||
<method name="Set">
|
||||
<arg type="s" name="interface_name" direction="in"/>
|
||||
<arg type="s" name="property_name" direction="in"/>
|
||||
<arg type="v" name="value" direction="in"/>
|
||||
</method>
|
||||
<signal name="PropertiesChanged">
|
||||
<arg type="s" name="interface_name"/>
|
||||
<arg type="a{sv}" name="changed_properties"/>
|
||||
<arg type="as" name="invalidated_properties"/>
|
||||
</signal>
|
||||
</interface>
|
||||
<interface name="org.freedesktop.DBus.Introspectable">
|
||||
<method name="Introspect">
|
||||
<arg type="s" name="xml_data" direction="out"/>
|
||||
</method>
|
||||
</interface>
|
||||
<interface name="org.freedesktop.DBus.Peer">
|
||||
<method name="Ping"/>
|
||||
<method name="GetMachineId">
|
||||
<arg type="s" name="machine_uuid" direction="out"/>
|
||||
</method>
|
||||
</interface>
|
||||
<interface name="org.freedesktop.Secret.Service">
|
||||
<method name="OpenSession">
|
||||
<arg type="s" name="algorithm" direction="in"/>
|
||||
<arg type="v" name="input" direction="in"/>
|
||||
<arg type="v" name="output" direction="out"/>
|
||||
<arg type="o" name="result" direction="out"/>
|
||||
</method>
|
||||
<method name="CreateCollection">
|
||||
<arg type="a{sv}" name="properties" direction="in"/>
|
||||
<arg type="s" name="alias" direction="in"/>
|
||||
<arg type="o" name="collection" direction="out"/>
|
||||
<arg type="o" name="prompt" direction="out"/>
|
||||
</method>
|
||||
<method name="SearchItems">
|
||||
<arg type="a{ss}" name="attributes" direction="in"/>
|
||||
<arg type="ao" name="unlocked" direction="out"/>
|
||||
<arg type="ao" name="locked" direction="out"/>
|
||||
</method>
|
||||
<method name="Unlock">
|
||||
<arg type="ao" name="objects" direction="in"/>
|
||||
<arg type="ao" name="unlocked" direction="out"/>
|
||||
<arg type="o" name="prompt" direction="out"/>
|
||||
</method>
|
||||
<method name="Lock">
|
||||
<arg type="ao" name="objects" direction="in"/>
|
||||
<arg type="ao" name="locked" direction="out"/>
|
||||
<arg type="o" name="Prompt" direction="out"/>
|
||||
</method>
|
||||
<method name="LockService"/>
|
||||
<method name="ChangeLock">
|
||||
<arg type="o" name="collection" direction="in"/>
|
||||
<arg type="o" name="prompt" direction="out"/>
|
||||
</method>
|
||||
<method name="GetSecrets">
|
||||
<arg type="ao" name="items" direction="in"/>
|
||||
<arg type="o" name="session" direction="in"/>
|
||||
<arg type="a{o(oayays)}" name="secrets" direction="out"/>
|
||||
</method>
|
||||
<method name="ReadAlias">
|
||||
<arg type="s" name="name" direction="in"/>
|
||||
<arg type="o" name="collection" direction="out"/>
|
||||
</method>
|
||||
<method name="SetAlias">
|
||||
<arg type="s" name="name" direction="in"/>
|
||||
<arg type="o" name="collection" direction="in"/>
|
||||
</method>
|
||||
<signal name="CollectionCreated">
|
||||
<arg type="o" name="collection"/>
|
||||
</signal>
|
||||
<signal name="CollectionDeleted">
|
||||
<arg type="o" name="collection"/>
|
||||
</signal>
|
||||
<signal name="CollectionChanged">
|
||||
<arg type="o" name="collection"/>
|
||||
</signal>
|
||||
<property type="ao" name="Collections" access="read"/>
|
||||
</interface>
|
||||
<interface name="org.gnome.keyring.InternalUnsupportedGuiltRiddenInterface">
|
||||
<method name="ChangeWithMasterPassword">
|
||||
<arg type="o" name="collection" direction="in"/>
|
||||
<arg type="(oayays)" name="original" direction="in"/>
|
||||
<arg type="(oayays)" name="master" direction="in"/>
|
||||
</method>
|
||||
<method name="ChangeWithPrompt">
|
||||
<arg type="o" name="collection" direction="in"/>
|
||||
<arg type="o" name="prompt" direction="out"/>
|
||||
</method>
|
||||
<method name="CreateWithMasterPassword">
|
||||
<arg type="a{sv}" name="attributes" direction="in"/>
|
||||
<arg type="(oayays)" name="master" direction="in"/>
|
||||
<arg type="o" name="collection" direction="out"/>
|
||||
</method>
|
||||
<method name="UnlockWithMasterPassword">
|
||||
<arg type="o" name="collection" direction="in"/>
|
||||
<arg type="(oayays)" name="master" direction="in"/>
|
||||
</method>
|
||||
</interface>
|
||||
<node name="session"/>
|
||||
<node name="collection"/>
|
||||
</node>
|
||||
24
lib/jeepney/tests/test_auth.py
Normal file
24
lib/jeepney/tests/test_auth.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
|
||||
from jeepney import auth
|
||||
|
||||
def test_make_auth_external():
|
||||
b = auth.make_auth_external()
|
||||
assert b.startswith(b'AUTH EXTERNAL')
|
||||
|
||||
def test_make_auth_anonymous():
|
||||
b = auth.make_auth_anonymous()
|
||||
assert b.startswith(b'AUTH ANONYMOUS')
|
||||
|
||||
def test_parser():
|
||||
p = auth.SASLParser()
|
||||
p.feed(b'OK 728d62bc2eb394')
|
||||
assert not p.authenticated
|
||||
p.feed(b'1ebbb0b42958b1e0d6\r\n')
|
||||
assert p.authenticated
|
||||
|
||||
def test_parser_rejected():
|
||||
p = auth.SASLParser()
|
||||
with pytest.raises(auth.AuthenticationError):
|
||||
p.feed(b'REJECTED EXTERNAL\r\n')
|
||||
assert not p.authenticated
|
||||
28
lib/jeepney/tests/test_bindgen.py
Normal file
28
lib/jeepney/tests/test_bindgen.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from io import StringIO
|
||||
import os.path
|
||||
|
||||
from jeepney.low_level import MessageType, HeaderFields
|
||||
from jeepney.bindgen import code_from_xml
|
||||
|
||||
sample_file = os.path.join(os.path.dirname(__file__), 'secrets_introspect.xml')
|
||||
|
||||
def test_bindgen():
|
||||
with open(sample_file) as f:
|
||||
xml = f.read()
|
||||
sio = StringIO()
|
||||
n_interfaces = code_from_xml(xml, path='/org/freedesktop/secrets',
|
||||
bus_name='org.freedesktop.secrets',
|
||||
fh=sio)
|
||||
# 5 interfaces defined, but we ignore Properties, Introspectable, Peer
|
||||
assert n_interfaces == 2
|
||||
|
||||
# Run the generated code, defining the message generator classes.
|
||||
binding_ns = {}
|
||||
exec(sio.getvalue(), binding_ns)
|
||||
Service = binding_ns['Service']
|
||||
|
||||
# Check basic functionality of the Service class
|
||||
assert Service.interface == 'org.freedesktop.Secret.Service'
|
||||
msg = Service().SearchItems({"service": "foo", "user": "bar"})
|
||||
assert msg.header.message_type is MessageType.method_call
|
||||
assert msg.header.fields[HeaderFields.destination] == 'org.freedesktop.secrets'
|
||||
24
lib/jeepney/tests/test_bus.py
Normal file
24
lib/jeepney/tests/test_bus.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
from testpath import modified_env
|
||||
|
||||
from jeepney import bus
|
||||
|
||||
def test_get_connectable_addresses():
|
||||
a = list(bus.get_connectable_addresses('unix:path=/run/user/1000/bus'))
|
||||
assert a == ['/run/user/1000/bus']
|
||||
|
||||
a = list(bus.get_connectable_addresses('unix:abstract=/tmp/foo'))
|
||||
assert a == ['\0/tmp/foo']
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
list(bus.get_connectable_addresses('unix:tmpdir=/tmp'))
|
||||
|
||||
def test_get_bus():
|
||||
with modified_env({
|
||||
'DBUS_SESSION_BUS_ADDRESS':'unix:path=/run/user/1000/bus',
|
||||
'DBUS_SYSTEM_BUS_ADDRESS': 'unix:path=/var/run/dbus/system_bus_socket'
|
||||
}):
|
||||
assert bus.get_bus('SESSION') == '/run/user/1000/bus'
|
||||
assert bus.get_bus('SYSTEM') == '/var/run/dbus/system_bus_socket'
|
||||
|
||||
assert bus.get_bus('unix:path=/run/user/1002/bus') == '/run/user/1002/bus'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user