matrix-nio backend start, QGuiApplication class

- Started work on the matrix-nio backend, which will be used instead
  of matrix-python-sdk for greater control and cleaner design

- Have an Application (QGuiApplication) class to habdle argument parsing
  and setting some Qt properties like application name
This commit is contained in:
miruka 2019-04-11 13:22:43 -04:00
parent 3b47fee77d
commit 4f9a47027c
12 changed files with 402 additions and 44 deletions

View File

@ -3,10 +3,11 @@
"""<SHORTDESC>"""
__pkg_name__ = "harmonyqml"
__version__ = "0.1.0"
__status__ = "Development"
# __status__ = "Production"
__pkg_name__ = "harmonyqml"
__pretty_name__ = "Harmony QML"
__version__ = "0.1.0"
__status__ = "Development"
# __status__ = "Production"
__author__ = "miruka"
__email__ = "miruka@disroot.org"

View File

@ -3,20 +3,10 @@
import sys
from PyQt5.QtGui import QGuiApplication
from .engine import Engine
from . import app
# logging.basicConfig(level=logging.INFO)
def run() -> None:
try:
sys.argv.index("--debug")
debug = True
except ValueError:
debug = False
app = QGuiApplication(sys.argv)
engine = Engine(app, debug=debug)
engine.show_window()
_ = app.Application(sys.argv)

26
harmonyqml/app.py Normal file
View File

@ -0,0 +1,26 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
from typing import List, Optional
from PyQt5.QtGui import QGuiApplication
from . import __about__
class Application(QGuiApplication):
def __init__(self, args: Optional[List[str]] = None) -> None:
try:
args.index("--debug") # type: ignore
debug = True
except (AttributeError, ValueError):
debug = False
super().__init__(args or [])
self.setApplicationName(__about__.__pkg_name__)
self.setApplicationDisplayName(__about__.__pretty_name__)
from .engine import Engine
engine = Engine(self, debug=debug)
engine.show_window()

View File

@ -4,28 +4,19 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union
from namedlist import namedlist
from PyQt5.QtCore import (
QAbstractListModel, QModelIndex, Qt, pyqtProperty, pyqtSlot, pyqtSignal
QAbstractListModel, QModelIndex, Qt, pyqtProperty, pyqtSlot
)
NewValue = Union[Mapping[str, Any], Sequence]
class _QtListModel(QAbstractListModel):
updated = pyqtSignal()
def __init__(self) -> None:
super().__init__()
self._ref_namedlist = None
self._roles: Tuple[str, ...] = ()
self._list: list = []
self._update_counter: int = 0
for sig in (self.dataChanged, self.layoutChanged, self.modelReset,
self.rowsInserted, self.rowsMoved, self.rowsRemoved):
sig.connect(self.updated.emit)
def roleNames(self) -> Dict[int, bytes]:
return {Qt.UserRole + i: bytes(f, "utf-8")
for i, f in enumerate(self._roles, 1)}
@ -70,7 +61,7 @@ class _QtListModel(QAbstractListModel):
@pyqtSlot(int, result="QVariantMap")
def get(self, index: int) -> Optional[Dict[str, Any]]:
def get(self, index: int) -> Dict[str, Any]:
return self._list[index]._asdict()
@ -82,7 +73,7 @@ class _QtListModel(QAbstractListModel):
self.endInsertRows()
@pyqtProperty(int, constant=True)
@pyqtProperty(int)
def count(self) -> int:
return self.rowCount()
@ -151,13 +142,6 @@ class _QtListModel(QAbstractListModel):
self.endRemoveRows()
@pyqtProperty(int, notify=updated)
def reloadThis(self) -> int:
# http://www.mardy.it/blog/2016/11/qml-trick-force-re-evaluation-of.html
self._update_counter += 1
return self._update_counter
class ListModel(MutableSequence):
def __init__(self, initial_data: Optional[List[NewValue]] = None) -> None:
super().__init__()

View File

@ -0,0 +1,4 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
from .backend import MatrixNioBackend

View File

@ -0,0 +1,27 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
from typing import Any, DefaultDict, Dict, NamedTuple, Optional
from PyQt5.QtCore import QDateTime, QObject, pyqtProperty, pyqtSlot
from matrix_client.user import User as MatrixUser
from ..base import Backend, User
from .client_manager import ClientManager
class MatrixNioBackend(Backend):
def __init__(self) -> None:
super().__init__()
self._client_manager = ClientManager()
# a = self._client_manager
# from PyQt5.QtCore import pyqtRemoveInputHook as PRI; import pdb; PRI(); pdb.set_trace()
self._client_manager.configLoad()
@pyqtProperty("QVariant")
def clientManager(self):
return self._client_manager

View File

@ -0,0 +1,85 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
import functools
from concurrent.futures import Future, ThreadPoolExecutor
from threading import Event
from typing import Callable, DefaultDict
from PyQt5.QtCore import QObject, pyqtSlot
import nio
import nio.responses as nr
# One pool per hostname/remote server;
# multiple Client for different accounts on the same server can exist.
_POOLS: DefaultDict[str, ThreadPoolExecutor] = \
DefaultDict(lambda: ThreadPoolExecutor(max_workers=6))
def futurize(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> Future:
return args[0].pool.submit(func, *args, **kwargs) # args[0] = self
return wrapper
class Client(QObject):
def __init__(self, hostname: str, username: str, device_id: str = ""
) -> None:
super().__init__()
host, *port = hostname.split(":")
self.host: str = host
self.port: int = int(port[0]) if port else 443
self.nio: nio.client.HttpClient = \
nio.client.HttpClient(self.host, username, device_id)
self.pool: ThreadPoolExecutor = _POOLS[self.host]
from .net import NetworkManager
self.net: NetworkManager = NetworkManager(self)
self._stop_sync: Event = Event()
def __repr__(self) -> str:
return "%s(host=%r, port=%r, user_id=%r)" % \
(type(self).__name__, self.host, self.port, self.nio.user_id)
@pyqtSlot(str)
@pyqtSlot(str, str)
@futurize
def login(self, password: str, device_name: str = "") -> None:
self.net.write(self.nio.connect())
self.net.talk(self.nio.login, password, device_name)
self.startSyncing()
@pyqtSlot(str, str, str)
@futurize
def resumeSession(self, user_id: str, token: str, device_id: str
) -> None:
self.net.write(self.nio.connect())
response = nr.LoginResponse(user_id, device_id, token)
self.nio.receive_response(response)
self.startSyncing()
@pyqtSlot()
@futurize
def logout(self) -> None:
self._stop_sync.set()
self.net.write(self.nio.disconnect())
@futurize
def startSyncing(self) -> None:
while True:
print(self, self.net.talk(self.nio.sync, timeout=10))
if self._stop_sync.is_set():
self._stop_sync.clear()
break

View File

@ -0,0 +1,147 @@
# Copyright 2018 miruka
# This file is part of harmonyqt, licensed under GPLv3.
import hashlib
import json
import os
import platform
import threading
from concurrent.futures import Future
from typing import Dict, Optional
from atomicfile import AtomicFile
from PyQt5.QtCore import QObject, QStandardPaths, pyqtProperty, pyqtSlot
from harmonyqml import __about__
from .client import Client
AccountConfig = Dict[str, Dict[str, str]]
_CONFIG_LOCK = threading.Lock()
# _CRYPT_DB_LOCK = threading.Lock()
class ClientManager(QObject):
def __init__(self) -> None:
super().__init__()
self._clients: Dict[str, Client] = {}
def __repr__(self) -> str:
return f"{type(self).__name__}(clients={self.clients!r})"
@pyqtProperty("QVariantMap")
def clients(self):
return self._clients
@pyqtSlot()
def configLoad(self) -> None:
for user_id, info in self.configAccounts().items():
cli = Client(info["hostname"], user_id)
def on_done(_: Future, cli=cli) -> None:
self._clients[cli.nio.user_id] = cli
cli.resumeSession(user_id, info["token"], info["device_id"])\
.add_done_callback(on_done)
@pyqtSlot(str, str, str)
@pyqtSlot(str, str, str)
def new(self, hostname: str, username: str, password: str,
device_id: str = "") -> None:
cli = Client(hostname, username, device_id)
def on_done(_: Future, cli=cli) -> None:
self._clients[cli.nio.user_id] = cli
cli.login(password, self.defaultDeviceName).add_done_callback(on_done)
@pyqtSlot(str)
def delete(self, user_id: str) -> None:
client = self._clients.pop(user_id, None)
if client:
client.logout()
@pyqtProperty(str)
def defaultDeviceName(self) -> str: # pylint: disable=no-self-use
os_ = f" on {platform.system()}".rstrip()
os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
return f"{__about__.__pretty_name__}{os}"
# Standard file paths
@staticmethod
def _get_standard_path(kind: QStandardPaths.StandardLocation,
file: str,
initial_content: Optional[str] = None) -> str:
relative_path = file.replace("/", os.sep)
path = QStandardPaths.locate(kind, relative_path)
if path:
return path
base_dir = QStandardPaths.writableLocation(kind)
path = f"{base_dir}{os.sep}{relative_path}"
os.makedirs(os.path.split(path)[0], exist_ok=True)
if initial_content is not None:
with AtomicFile(path, "w") as new:
new.write(initial_content)
return path
def getAccountConfigPath(self) -> str:
return self._get_standard_path(
QStandardPaths.AppConfigLocation, "accounts.json", "[]"
)
def getCryptDBPath(self, user_id: str) -> str:
safe_filename = hashlib.md5(user_id.encode("utf-8")).hexdigest()
return self._get_standard_path(
QStandardPaths.AppDataLocation, f"encryption/{safe_filename}.db"
)
# Config file operations
def configAccounts(self) -> AccountConfig:
with open(self.getAccountConfigPath(), "r") as file:
return json.loads(file.read().strip()) or {}
@pyqtSlot(Client)
def configAdd(self, client: Client) -> None:
self._write_config({
**self.configAccounts(),
**{client.nio.user_id: {
"hostname": client.nio.host,
"token": client.nio.access_token,
"device_id": client.nio.device_id,
}}
})
@pyqtSlot(str)
def configDelete(self, user_id: str) -> None:
self._write_config({
uid: info
for uid, info in self.configAccounts().items() if uid != user_id
})
def _write_config(self, accounts: AccountConfig) -> None:
js = json.dumps(accounts, indent=4, ensure_ascii=False, sort_keys=True)
with _CONFIG_LOCK:
with AtomicFile(self.getAccountConfigPath(), "w") as new:
new.write(js)

View File

@ -0,0 +1,95 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
import logging
import socket
import ssl
import time
from typing import Callable, Optional, Tuple
from uuid import UUID
import nio.responses as nr
from .client import Client
OptSock = Optional[ssl.SSLSocket]
NioRequestFunc = Callable[..., Tuple[UUID, bytes]]
class NioErrorResponse(Exception):
def __init__(self, response: nr.ErrorResponse) -> None:
self.response = response
super().__init__(str(response))
class NetworkManager:
def __init__(self, client: Client) -> None:
self.client = client
self._ssl_context: ssl.SSLContext = ssl.create_default_context()
self._ssl_session: Optional[ssl.SSLSession] = None
def _get_socket(self) -> ssl.SSLSocket:
sock = self._ssl_context.wrap_socket( # type: ignore
socket.create_connection((self.client.host, self.client.port)),
server_hostname = self.client.host,
session = self._ssl_session,
)
self._ssl_session = self._ssl_session or sock.session
return sock
@staticmethod
def _close_socket(sock: socket.socket) -> None:
sock.shutdown(how=socket.SHUT_RDWR)
sock.close()
def read(self, with_sock: OptSock = None) -> nr.Response:
sock = with_sock or self._get_socket()
response = None
while not response:
self.client.nio.receive(sock.recv(4096))
response = self.client.nio.next_response()
if isinstance(response, nr.ErrorResponse):
raise NioErrorResponse(response)
if not with_sock:
self._close_socket(sock)
return response
def write(self, data: bytes, with_sock: OptSock = None) -> None:
sock = with_sock or self._get_socket()
sock.sendall(data)
if not with_sock:
self._close_socket(sock)
def talk(self, nio_func: NioRequestFunc, *args, **kwargs) -> nr.Response:
while True:
to_send = nio_func(*args, **kwargs)[1]
sock = self._get_socket()
try:
self.write(to_send, sock)
response = self.read(sock)
except NioErrorResponse as err:
logging.error("read bad response: %s", err)
self._close_socket(sock)
time.sleep(10)
except Exception as err:
logging.error("talk exception: %r", err)
break
else:
break
self._close_socket(sock)
return response

View File

@ -24,10 +24,10 @@ Controls1.SplitView {
}
id: "pageStack"
initialItem: Chat.Root {
user: Backend.accountsModel.get(0)
room: Backend.roomsModel[Backend.accountsModel.get(0).user_id].get(0)
}
// initialItem: Chat.Root {
//user: Backend.accountsModel.get(0)
//room: Backend.roomsModel[Backend.accountsModel.get(0).user_id].get(0)
//}
onCurrentItemChanged: currentItem.forceActiveFocus()

View File

@ -5,7 +5,6 @@ ApplicationWindow {
visible: true
width: 640
height: 700
title: "Harmony QML"
Loader {
anchors.fill: parent

View File

@ -7,23 +7,23 @@ from pathlib import Path
from typing import Generator, Optional
from PyQt5.QtCore import QFileSystemWatcher, QObject, QTimer
from PyQt5.QtGui import QGuiApplication
from PyQt5.QtQml import QQmlApplicationEngine
from .__about__ import __doc__
from .backend import DummyBackend
from .app import Application
from .backend.matrix_nio.backend import MatrixNioBackend as CurrentBackend
# logging.basicConfig(level=logging.INFO)
class Engine(QQmlApplicationEngine):
def __init__(self,
app: QGuiApplication,
app: Application,
debug: bool = False,
parent: Optional[QObject] = None) -> None:
super().__init__(parent)
self.app = app
self.backend = DummyBackend()
self.backend = CurrentBackend()
self.app_dir = Path(sys.argv[0]).resolve().parent
# Set QML properties