Document backend & qml_bridge + minor code changes

This commit is contained in:
miruka 2019-12-18 08:14:35 -04:00
parent 87b262ebee
commit 23be12fb60
4 changed files with 131 additions and 84 deletions

View File

@ -1,3 +1,5 @@
"""This package provides a Python backend accessible from the QML UI side."""
__app_name__ = "harmonyqml"
__display_name__ = "Harmony QML"
__version__ = "0.3.0"

View File

@ -1,8 +1,9 @@
import asyncio
import logging as log
import sys
import traceback
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
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.model_store import ModelStore
# Logging configuration
log.getLogger().setLevel(log.INFO)
nio.logger_group.level = nio.log.logbook.ERROR
nio.log.logbook.StreamHandler(sys.stderr).push_application()
class Backend:
"""Manage matrix clients and provide other useful general methods."""
def __init__(self) -> None:
self.appdirs = AppDirs(appname=__app_name__, roaming=True)
@ -65,6 +69,7 @@ class Backend:
device_id: Optional[str] = None,
homeserver: str = "https://matrix.org",
) -> str:
"""Create and register a `MatrixClient`, login and return a user ID."""
client = MatrixClient(
self, user=user, homeserver=homeserver, device_id=device_id,
@ -86,6 +91,7 @@ class Backend:
token: str,
device_id: str,
homeserver: str = "https://matrix.org") -> None:
"""Create and register a `MatrixClient` with known account details."""
client = MatrixClient(
backend=self,
@ -99,6 +105,8 @@ class Backend:
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:
await self.resume_client(
user_id = user_id,
@ -115,6 +123,8 @@ class Backend:
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)
if client:
self.models[Account].pop(user_id, None)
@ -123,29 +133,105 @@ class Backend:
await self.saved_accounts.delete(user_id)
async def wait_until_client_exists(self, user_id: str) -> None:
loops = 0
async def get_client(self, user_id: str) -> MatrixClient:
"""Wait until a `MatrixClient` is registered in model and return it."""
failures = 0
while True:
if user_id in self.clients:
return
return self.clients[user_id]
if loops and loops % 100 == 0: # every 10s except first time
log.warning("Waiting for account %s to exist, %ds passed",
user_id, loops // 10)
if failures and failures % 100 == 0: # every 10s except first time
log.warning(
"Client %r not found after %ds, stack trace:\n%s",
user_id, failures / 10, traceback.format_stack(),
)
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
@staticmethod
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])
async def load_settings(self) -> tuple:
"""Return parsed user config files."""
from .config_files import Theme
settings = await self.ui_settings.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]]:
"""Return a flat list of accounts and their joined rooms for QML."""
data = []
for account in sorted(self.models[Account].values()):
@ -175,65 +263,3 @@ class Backend:
})
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

View File

@ -1,3 +1,5 @@
"""Install `uvloop` if possible and provide a `QmlBridge`."""
import asyncio
import logging as log
import signal
@ -19,24 +21,35 @@ else:
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:
self.backend = Backend()
self.backend: Backend = Backend()
self.loop = asyncio.get_event_loop()
Thread(target=self._start_loop_in_thread).start()
self._loop = asyncio.get_event_loop()
Thread(target=self._start_asyncio_loop).start()
def _start_loop_in_thread(self) -> None:
asyncio.set_event_loop(self.loop)
self.loop.run_forever()
def _run_coro_in_loop(self, coro: Coroutine) -> Future:
return asyncio.run_coroutine_threadsafe(coro, self.loop)
def _start_asyncio_loop(self) -> None:
asyncio.set_event_loop(self._loop)
self._loop.run_forever()
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:
"""Send a PyOtherSide event with the coro's result/exception."""
result = exception = trace = None
try:
@ -47,7 +60,7 @@ class QmlBridge:
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)
return future
@ -55,25 +68,31 @@ class QmlBridge:
def call_backend_coro(
self, name: str, uuid: str, args: Sequence[str] = (),
) -> Future:
"""Schedule a `Backend` coroutine and return a `Future`."""
return self._call_coro(attrgetter(name)(self.backend)(*args), uuid)
def call_client_coro(
self, user_id: str, name: str, uuid: str, args: Sequence[str] = (),
) -> Future:
"""Schedule a `MatrixClient` coroutine and return a `Future`."""
client = self.backend.clients[user_id]
return self._call_coro(attrgetter(name)(client)(*args), uuid)
def pdb(self, additional_data: Sequence = ()) -> None:
"""Call the RemotePdb debugger; define some conveniance variables."""
ad = additional_data # noqa
rc = self._run_coro_in_loop # noqa
ba = self.backend # noqa
mo = self.backend.models # noqa
cl = self.backend.clients
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
p = print # pdb's `p` doesn't print a class's __str__ # noqa

View File

@ -69,7 +69,7 @@ Python {
) {
let future = privates.makeFuture()
callCoro("wait_until_client_exists", [accountId], () => {
callCoro("get_client", [accountId], () => {
let uuid = accountId + "." + name + "." + CppUtils.uuid()
privates.pendingCoroutines[uuid] = {onSuccess, onError}