diff --git a/src/backend/__init__.py b/src/backend/__init__.py index 3c1062e6..b4495bed 100644 --- a/src/backend/__init__.py +++ b/src/backend/__init__.py @@ -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" diff --git a/src/backend/backend.py b/src/backend/backend.py index 524e927f..afec514f 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -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 diff --git a/src/backend/qml_bridge.py b/src/backend/qml_bridge.py index f797ec6d..ea63052f 100644 --- a/src/backend/qml_bridge.py +++ b/src/backend/qml_bridge.py @@ -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 diff --git a/src/gui/PythonBridge/PythonBridge.qml b/src/gui/PythonBridge/PythonBridge.qml index f8f7ae74..d74f87fe 100644 --- a/src/gui/PythonBridge/PythonBridge.qml +++ b/src/gui/PythonBridge/PythonBridge.qml @@ -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}