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" __app_name__ = "harmonyqml"
__display_name__ = "Harmony QML" __display_name__ = "Harmony QML"
__version__ = "0.3.0" __version__ = "0.3.0"

View File

@ -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

View File

@ -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

View File

@ -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}