Document backend & qml_bridge + minor code changes
This commit is contained in:
parent
87b262ebee
commit
23be12fb60
|
@ -1,3 +1,5 @@
|
||||||
|
"""This package provides a Python backend accessible from the QML UI side."""
|
||||||
|
|
||||||
__app_name__ = "harmonyqml"
|
__app_name__ = "harmonyqml"
|
||||||
__display_name__ = "Harmony QML"
|
__display_name__ = "Harmony QML"
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.3.0"
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging as log
|
import logging as log
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple
|
from typing import Any, DefaultDict, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import hsluv
|
import hsluv
|
||||||
from appdirs import AppDirs
|
from appdirs import AppDirs
|
||||||
|
@ -15,12 +16,15 @@ from .matrix_client import MatrixClient
|
||||||
from .models.items import Account, Device, Event, Member, Room, Upload
|
from .models.items import Account, Device, Event, Member, Room, Upload
|
||||||
from .models.model_store import ModelStore
|
from .models.model_store import ModelStore
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
log.getLogger().setLevel(log.INFO)
|
log.getLogger().setLevel(log.INFO)
|
||||||
nio.logger_group.level = nio.log.logbook.ERROR
|
nio.logger_group.level = nio.log.logbook.ERROR
|
||||||
nio.log.logbook.StreamHandler(sys.stderr).push_application()
|
nio.log.logbook.StreamHandler(sys.stderr).push_application()
|
||||||
|
|
||||||
|
|
||||||
class Backend:
|
class Backend:
|
||||||
|
"""Manage matrix clients and provide other useful general methods."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.appdirs = AppDirs(appname=__app_name__, roaming=True)
|
self.appdirs = AppDirs(appname=__app_name__, roaming=True)
|
||||||
|
|
||||||
|
@ -65,6 +69,7 @@ class Backend:
|
||||||
device_id: Optional[str] = None,
|
device_id: Optional[str] = None,
|
||||||
homeserver: str = "https://matrix.org",
|
homeserver: str = "https://matrix.org",
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""Create and register a `MatrixClient`, login and return a user ID."""
|
||||||
|
|
||||||
client = MatrixClient(
|
client = MatrixClient(
|
||||||
self, user=user, homeserver=homeserver, device_id=device_id,
|
self, user=user, homeserver=homeserver, device_id=device_id,
|
||||||
|
@ -86,6 +91,7 @@ class Backend:
|
||||||
token: str,
|
token: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
homeserver: str = "https://matrix.org") -> None:
|
homeserver: str = "https://matrix.org") -> None:
|
||||||
|
"""Create and register a `MatrixClient` with known account details."""
|
||||||
|
|
||||||
client = MatrixClient(
|
client = MatrixClient(
|
||||||
backend=self,
|
backend=self,
|
||||||
|
@ -99,6 +105,8 @@ class Backend:
|
||||||
|
|
||||||
|
|
||||||
async def load_saved_accounts(self) -> Tuple[str, ...]:
|
async def load_saved_accounts(self) -> Tuple[str, ...]:
|
||||||
|
"""Call `resume_client` for all saved accounts in user config."""
|
||||||
|
|
||||||
async def resume(user_id: str, info: Dict[str, str]) -> str:
|
async def resume(user_id: str, info: Dict[str, str]) -> str:
|
||||||
await self.resume_client(
|
await self.resume_client(
|
||||||
user_id = user_id,
|
user_id = user_id,
|
||||||
|
@ -115,6 +123,8 @@ class Backend:
|
||||||
|
|
||||||
|
|
||||||
async def logout_client(self, user_id: str) -> None:
|
async def logout_client(self, user_id: str) -> None:
|
||||||
|
"""Log a `MatrixClient` out and unregister it from our models."""
|
||||||
|
|
||||||
client = self.clients.pop(user_id, None)
|
client = self.clients.pop(user_id, None)
|
||||||
if client:
|
if client:
|
||||||
self.models[Account].pop(user_id, None)
|
self.models[Account].pop(user_id, None)
|
||||||
|
@ -123,29 +133,105 @@ class Backend:
|
||||||
await self.saved_accounts.delete(user_id)
|
await self.saved_accounts.delete(user_id)
|
||||||
|
|
||||||
|
|
||||||
async def wait_until_client_exists(self, user_id: str) -> None:
|
async def get_client(self, user_id: str) -> MatrixClient:
|
||||||
loops = 0
|
"""Wait until a `MatrixClient` is registered in model and return it."""
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if user_id in self.clients:
|
if user_id in self.clients:
|
||||||
return
|
return self.clients[user_id]
|
||||||
|
|
||||||
if loops and loops % 100 == 0: # every 10s except first time
|
if failures and failures % 100 == 0: # every 10s except first time
|
||||||
log.warning("Waiting for account %s to exist, %ds passed",
|
log.warning(
|
||||||
user_id, loops // 10)
|
"Client %r not found after %ds, stack trace:\n%s",
|
||||||
|
user_id, failures / 10, traceback.format_stack(),
|
||||||
|
)
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
loops += 1
|
failures += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def get_any_client(self) -> MatrixClient:
|
||||||
|
"""Return any healthy syncing `MatrixClient` registered in model."""
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
for client in self.clients.values():
|
||||||
|
if client.syncing:
|
||||||
|
return client
|
||||||
|
|
||||||
|
if failures and failures % 300 == 0:
|
||||||
|
log.warn(
|
||||||
|
"No healthy client found after %ds, stack trace:\n%s",
|
||||||
|
failures / 10, traceback.format_stack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
failures += 1
|
||||||
|
|
||||||
|
|
||||||
|
# Client functions that don't need authentification
|
||||||
|
|
||||||
|
async def get_profile(self, user_id: str) -> nio.ProfileGetResponse:
|
||||||
|
"""Cache and return the matrix profile of `user_id`."""
|
||||||
|
|
||||||
|
if user_id in self.profile_cache:
|
||||||
|
return self.profile_cache[user_id]
|
||||||
|
|
||||||
|
async with self.get_profile_locks[user_id]:
|
||||||
|
client = await self.get_any_client()
|
||||||
|
response = await client.get_profile(user_id)
|
||||||
|
|
||||||
|
if isinstance(response, nio.ProfileGetError):
|
||||||
|
raise MatrixError.from_nio(response)
|
||||||
|
|
||||||
|
self.profile_cache[user_id] = response
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def thumbnail(
|
||||||
|
self, server_name: str, media_id: str, width: int, height: int,
|
||||||
|
) -> nio.ThumbnailResponse:
|
||||||
|
"""Return thumbnail for a matrix media."""
|
||||||
|
|
||||||
|
args = (server_name, media_id, width, height)
|
||||||
|
client = await self.get_any_client()
|
||||||
|
response = await client.thumbnail(*args)
|
||||||
|
|
||||||
|
if isinstance(response, nio.ThumbnailError):
|
||||||
|
raise MatrixError.from_nio(response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def download(
|
||||||
|
self, server_name: str, media_id: str,
|
||||||
|
) -> nio.DownloadResponse:
|
||||||
|
"""Return the content of a matrix media."""
|
||||||
|
|
||||||
|
client = await self.get_any_client()
|
||||||
|
response = await client.download(server_name, media_id)
|
||||||
|
|
||||||
|
if isinstance(response, nio.DownloadError):
|
||||||
|
raise MatrixError.from_nio(response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# General functions
|
# General functions
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hsluv(hue: int, saturation: int, lightness: int) -> List[float]:
|
def hsluv(hue: int, saturation: int, lightness: int) -> List[float]:
|
||||||
# (0-360, 0-100, 0-100) -> [0-1, 0-1, 0-1]
|
"""Convert HSLuv (0-360, 0-100, 0-100) to RGB (0-1, 0-1, 0-1) color."""
|
||||||
|
|
||||||
return hsluv.hsluv_to_rgb([hue, saturation, lightness])
|
return hsluv.hsluv_to_rgb([hue, saturation, lightness])
|
||||||
|
|
||||||
|
|
||||||
async def load_settings(self) -> tuple:
|
async def load_settings(self) -> tuple:
|
||||||
|
"""Return parsed user config files."""
|
||||||
|
|
||||||
from .config_files import Theme
|
from .config_files import Theme
|
||||||
settings = await self.ui_settings.read()
|
settings = await self.ui_settings.read()
|
||||||
ui_state = await self.ui_state.read()
|
ui_state = await self.ui_state.read()
|
||||||
|
@ -156,6 +242,8 @@ class Backend:
|
||||||
|
|
||||||
|
|
||||||
async def get_flat_mainpane_data(self) -> List[Dict[str, Any]]:
|
async def get_flat_mainpane_data(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Return a flat list of accounts and their joined rooms for QML."""
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
|
|
||||||
for account in sorted(self.models[Account].values()):
|
for account in sorted(self.models[Account].values()):
|
||||||
|
@ -175,65 +263,3 @@ class Backend:
|
||||||
})
|
})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
# Client functions that don't need authentification
|
|
||||||
|
|
||||||
async def _any_client(self, caller: Callable, *args, **kw) -> MatrixClient:
|
|
||||||
failures = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
for client in self.clients.values():
|
|
||||||
if client.syncing:
|
|
||||||
return client
|
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
failures += 1
|
|
||||||
|
|
||||||
if failures and failures % 300 == 0:
|
|
||||||
log.warn(
|
|
||||||
"No syncing client found after %ds of wait for %s %r %r",
|
|
||||||
failures / 10, caller.__name__, args, kw,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_profile(self, user_id: str) -> nio.ProfileGetResponse:
|
|
||||||
if user_id in self.profile_cache:
|
|
||||||
return self.profile_cache[user_id]
|
|
||||||
|
|
||||||
async with self.get_profile_locks[user_id]:
|
|
||||||
client = await self._any_client(self.get_profile, user_id)
|
|
||||||
response = await client.get_profile(user_id)
|
|
||||||
|
|
||||||
if isinstance(response, nio.ProfileGetError):
|
|
||||||
raise MatrixError.from_nio(response)
|
|
||||||
|
|
||||||
self.profile_cache[user_id] = response
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
async def thumbnail(
|
|
||||||
self, server_name: str, media_id: str, width: int, height: int,
|
|
||||||
) -> nio.ThumbnailResponse:
|
|
||||||
|
|
||||||
args = (server_name, media_id, width, height)
|
|
||||||
client = await self._any_client(self.thumbnail, *args)
|
|
||||||
response = await client.thumbnail(*args)
|
|
||||||
|
|
||||||
if isinstance(response, nio.ThumbnailError):
|
|
||||||
raise MatrixError.from_nio(response)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
async def download(
|
|
||||||
self, server_name: str, media_id: str,
|
|
||||||
) -> nio.DownloadResponse:
|
|
||||||
|
|
||||||
client = await self._any_client(self.download, server_name, media_id)
|
|
||||||
response = await client.download(server_name, media_id)
|
|
||||||
|
|
||||||
if isinstance(response, nio.DownloadError):
|
|
||||||
raise MatrixError.from_nio(response)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
"""Install `uvloop` if possible and provide a `QmlBridge`."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging as log
|
import logging as log
|
||||||
import signal
|
import signal
|
||||||
|
@ -19,24 +21,35 @@ else:
|
||||||
|
|
||||||
|
|
||||||
class QmlBridge:
|
class QmlBridge:
|
||||||
|
"""Setup asyncio and provide synchronous methods to call coroutines.
|
||||||
|
|
||||||
|
A thread is created to run the asyncio loop in, to ensure all calls from
|
||||||
|
QML return instantly.
|
||||||
|
Methods are provided for QML to call coroutines using PyOtherSide, which
|
||||||
|
doesn't have this ability out of the box.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
backend: The `Backend` containing the coroutines of interest and
|
||||||
|
`MatrixClient` objects.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.backend = Backend()
|
self.backend: Backend = Backend()
|
||||||
|
|
||||||
self.loop = asyncio.get_event_loop()
|
self._loop = asyncio.get_event_loop()
|
||||||
Thread(target=self._start_loop_in_thread).start()
|
Thread(target=self._start_asyncio_loop).start()
|
||||||
|
|
||||||
|
|
||||||
def _start_loop_in_thread(self) -> None:
|
def _start_asyncio_loop(self) -> None:
|
||||||
asyncio.set_event_loop(self.loop)
|
asyncio.set_event_loop(self._loop)
|
||||||
self.loop.run_forever()
|
self._loop.run_forever()
|
||||||
|
|
||||||
|
|
||||||
def _run_coro_in_loop(self, coro: Coroutine) -> Future:
|
|
||||||
return asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
||||||
|
|
||||||
|
|
||||||
def _call_coro(self, coro: Coroutine, uuid: str) -> Future:
|
def _call_coro(self, coro: Coroutine, uuid: str) -> Future:
|
||||||
|
"""Schedule a coroutine to run in our thread and return a `Future`."""
|
||||||
|
|
||||||
def on_done(future: Future) -> None:
|
def on_done(future: Future) -> None:
|
||||||
|
"""Send a PyOtherSide event with the coro's result/exception."""
|
||||||
result = exception = trace = None
|
result = exception = trace = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -47,7 +60,7 @@ class QmlBridge:
|
||||||
|
|
||||||
CoroutineDone(uuid, result, exception, trace)
|
CoroutineDone(uuid, result, exception, trace)
|
||||||
|
|
||||||
future = self._run_coro_in_loop(coro)
|
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||||
future.add_done_callback(on_done)
|
future.add_done_callback(on_done)
|
||||||
return future
|
return future
|
||||||
|
|
||||||
|
@ -55,25 +68,31 @@ class QmlBridge:
|
||||||
def call_backend_coro(
|
def call_backend_coro(
|
||||||
self, name: str, uuid: str, args: Sequence[str] = (),
|
self, name: str, uuid: str, args: Sequence[str] = (),
|
||||||
) -> Future:
|
) -> Future:
|
||||||
|
"""Schedule a `Backend` coroutine and return a `Future`."""
|
||||||
|
|
||||||
return self._call_coro(attrgetter(name)(self.backend)(*args), uuid)
|
return self._call_coro(attrgetter(name)(self.backend)(*args), uuid)
|
||||||
|
|
||||||
|
|
||||||
def call_client_coro(
|
def call_client_coro(
|
||||||
self, user_id: str, name: str, uuid: str, args: Sequence[str] = (),
|
self, user_id: str, name: str, uuid: str, args: Sequence[str] = (),
|
||||||
) -> Future:
|
) -> Future:
|
||||||
|
"""Schedule a `MatrixClient` coroutine and return a `Future`."""
|
||||||
|
|
||||||
client = self.backend.clients[user_id]
|
client = self.backend.clients[user_id]
|
||||||
return self._call_coro(attrgetter(name)(client)(*args), uuid)
|
return self._call_coro(attrgetter(name)(client)(*args), uuid)
|
||||||
|
|
||||||
|
|
||||||
def pdb(self, additional_data: Sequence = ()) -> None:
|
def pdb(self, additional_data: Sequence = ()) -> None:
|
||||||
|
"""Call the RemotePdb debugger; define some conveniance variables."""
|
||||||
|
|
||||||
ad = additional_data # noqa
|
ad = additional_data # noqa
|
||||||
rc = self._run_coro_in_loop # noqa
|
|
||||||
ba = self.backend # noqa
|
ba = self.backend # noqa
|
||||||
mo = self.backend.models # noqa
|
mo = self.backend.models # noqa
|
||||||
cl = self.backend.clients
|
cl = self.backend.clients
|
||||||
gcl = lambda user: cl[f"@{user}:matrix.org"] # noqa
|
gcl = lambda user: cl[f"@{user}:matrix.org"] # noqa
|
||||||
|
|
||||||
|
rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa
|
||||||
|
|
||||||
from .models.items import Account, Room, Member, Event, Device # noqa
|
from .models.items import Account, Room, Member, Event, Device # noqa
|
||||||
|
|
||||||
p = print # pdb's `p` doesn't print a class's __str__ # noqa
|
p = print # pdb's `p` doesn't print a class's __str__ # noqa
|
||||||
|
|
|
@ -69,7 +69,7 @@ Python {
|
||||||
) {
|
) {
|
||||||
let future = privates.makeFuture()
|
let future = privates.makeFuture()
|
||||||
|
|
||||||
callCoro("wait_until_client_exists", [accountId], () => {
|
callCoro("get_client", [accountId], () => {
|
||||||
let uuid = accountId + "." + name + "." + CppUtils.uuid()
|
let uuid = accountId + "." + name + "." + CppUtils.uuid()
|
||||||
|
|
||||||
privates.pendingCoroutines[uuid] = {onSuccess, onError}
|
privates.pendingCoroutines[uuid] = {onSuccess, onError}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user