Refactor user_files module & add live reloading

- Cleaner design for the backend user_files classes and simplified
  interaction with QML
- Config and theme files will now automatically reload when changed on
  disk
- Removed manual reload keybind and button
This commit is contained in:
miruka 2020-10-05 03:06:07 -04:00
parent 00c468384a
commit 75fbf23b21
13 changed files with 249 additions and 230 deletions

View File

@ -10,6 +10,7 @@ mistune >= 0.8.4, < 0.9
pymediainfo >= 4.2.1, < 5
plyer >= 1.4.3, < 2
sortedcontainers >= 2.2.2, < 3
watchgod >= 0.6, < 0.7
dbus-python >= 1.2.16, < 2; platform_system == "Linux"
async_generator >= 1.10, < 2; python_version < "3.7"

View File

@ -29,7 +29,7 @@ from .models.model import Model
from .models.model_store import ModelStore
from .presence import Presence
from .sso_server import SSOServer
from .user_files import Accounts, History, Theme, UISettings, UIState
from .user_files import Accounts, History, Theme, Settings, UIState
# Logging configuration
log.getLogger().setLevel(log.INFO)
@ -43,7 +43,7 @@ class Backend:
Attributes:
saved_accounts: User config file for saved matrix account.
ui_settings: User config file for QML interface settings.
settings: User config file for general UI and backend settings.
ui_state: User data file for saving/restoring QML UI state.
@ -103,12 +103,13 @@ class Backend:
def __init__(self) -> None:
self.appdirs = AppDirs(appname=__app_name__, roaming=True)
self.models = ModelStore()
self.saved_accounts = Accounts(self)
self.ui_settings = UISettings(self)
self.settings = Settings(self)
self.ui_state = UIState(self)
self.history = History(self)
self.models = ModelStore()
self.theme = Theme(self, self.settings["theme"])
self.clients: Dict[str, MatrixClient] = {}
@ -296,8 +297,8 @@ class Backend:
return user_id
return await asyncio.gather(*(
resume(uid, info)
for uid, info in (await self.saved_accounts.read()).items()
resume(user_id, info)
for user_id, info in self.saved_accounts.items()
if info.get("enabled", True)
))
@ -325,7 +326,7 @@ class Backend:
self.models[user_id, "rooms"].clear()
await self.saved_accounts.delete(user_id)
await self.saved_accounts.forget(user_id)
async def terminate_clients(self) -> None:
@ -426,20 +427,14 @@ class Backend:
return path
async def load_settings(self) -> tuple:
"""Return parsed user config files."""
settings = await self.ui_settings.read()
ui_state = await self.ui_state.read()
history = await self.history.read()
theme = await Theme(self, settings["theme"]).read()
state_data = self.ui_state._data
if state_data:
for user, collapse in state_data["collapseAccounts"].items():
self.models["all_rooms"].set_account_collapse(user, collapse)
return (settings, ui_state, history, theme)
async def get_settings(self) -> Tuple[Settings, UIState, History, str]:
"""Return parsed user config files for QML."""
return (
self.settings.data,
self.ui_state.data,
self.history.data,
self.theme.data,
)
async def set_string_filter(self, model_id: SyncId, value: str) -> None:

View File

@ -285,7 +285,7 @@ class MatrixClient(nio.AsyncClient):
await super().login(password, self.default_device_name, token)
order = 0
saved_accounts = await self.backend.saved_accounts.read()
saved_accounts = self.backend.saved_accounts
if saved_accounts:
order = max(
@ -493,7 +493,7 @@ class MatrixClient(nio.AsyncClient):
utils.dict_update_recursive(first, self.low_limit_filter)
if self.backend.ui_settings["hideUnknownEvents"]:
if self.backend.settings["hideUnknownEvents"]:
first["room"]["timeline"]["not_types"].extend(
self.no_unknown_events_filter
["room"]["timeline"]["not_types"],
@ -1553,7 +1553,7 @@ class MatrixClient(nio.AsyncClient):
if save:
account.save_presence = True
await self.backend.saved_accounts.update(
await self.backend.saved_accounts.set(
self.user_id, presence=presence, status_msg=status_msg,
)
else:
@ -1912,7 +1912,7 @@ class MatrixClient(nio.AsyncClient):
local_unreads = local_unreads,
local_highlights = local_highlights,
lexical_sorting = self.backend.ui_settings["lexicalRoomSorting"],
lexical_sorting = self.backend.settings["lexicalRoomSorting"],
bookmarked = room.room_id in bookmarks.get(self.user_id, {}),
)

View File

@ -498,7 +498,7 @@ class NioCallbacks:
# Membership changes
if not prev or membership != prev_membership:
if self.client.backend.ui_settings["hideMembershipEvents"]:
if self.client.backend.settings["hideMembershipEvents"]:
return None
reason = escape(
@ -564,7 +564,7 @@ class NioCallbacks:
avatar_url = now.get("avatar_url") or "",
)
if self.client.backend.ui_settings["hideProfileChangeEvents"]:
if self.client.backend.settings["hideProfileChangeEvents"]:
return None
return (
@ -682,7 +682,7 @@ class NioCallbacks:
async def onUnknownEvent(
self, room: nio.MatrixRoom, ev: nio.UnknownEvent,
) -> None:
if self.client.backend.ui_settings["hideUnknownEvents"]:
if self.client.backend.settings["hideUnknownEvents"]:
await self.client.register_nio_room(room)
return
@ -846,7 +846,7 @@ class NioCallbacks:
status_msg = account.status_msg
state = Presence.State.invisible
await self.client.backend.saved_accounts.update(
await self.client.backend.saved_accounts.set(
user_id = ev.user_id,
status_msg = status_msg,
presence = state.value,

View File

@ -3,7 +3,7 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Union
from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Type, Union
import pyotherside
@ -11,6 +11,7 @@ from .utils import serialize_value_for_qml
if TYPE_CHECKING:
from .models import SyncId
from .user_files import UserFile
@dataclass
@ -69,6 +70,14 @@ class LoopException(PyOtherSideEvent):
traceback: Optional[str] = None
@dataclass
class UserFileChanged(PyOtherSideEvent):
"""Indicate that a config or data file changed on disk."""
type: Type["UserFile"] = field()
new_data: Any = field()
@dataclass
class ModelEvent(PyOtherSideEvent):
"""Base class for model change events."""

View File

@ -7,179 +7,211 @@ import asyncio
import json
import os
import platform
from collections.abc import MutableMapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional
from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Optional, Tuple
import aiofiles
from watchgod import Change, awatch
import pyotherside
from .pyotherside_events import UserFileChanged
from .theme_parser import convert_to_qml
from .utils import atomic_write, dict_update_recursive
if TYPE_CHECKING:
from .backend import Backend
JsonData = Dict[str, Any]
WRITE_LOCK = asyncio.Lock()
@dataclass
class DataFile:
"""Base class representing a user data file."""
class UserFile:
"""Base class representing a user config or data file."""
is_config: ClassVar[bool] = False
create_missing: ClassVar[bool] = True
backend: "Backend" = field(repr=False)
filename: str = field()
_to_write: Optional[str] = field(init=False, default=None)
data: Any = field(init=False, default_factory=dict)
_need_write: bool = field(init=False, default=False)
_wrote: bool = field(init=False, default=False)
_reader: Optional[asyncio.Future] = field(init=False, default=None)
_writer: Optional[asyncio.Future] = field(init=False, default=None)
def __post_init__(self) -> None:
asyncio.ensure_future(self._write_loop())
try:
self.data, save = self.deserialized(self.path.read_text())
except FileNotFoundError:
self.data = self.default_data
self._need_write = self.create_missing
else:
if save:
self.save()
self._reader = asyncio.ensure_future(self._start_reader())
self._writer = asyncio.ensure_future(self._start_writer())
@property
def path(self) -> Path:
"""Full path of the file, even if it doesn't exist yet."""
"""Full path of the file, can exist or not exist."""
raise NotImplementedError()
if self.is_config:
return Path(
os.environ.get("MIRAGE_CONFIG_DIR") or
self.backend.appdirs.user_config_dir,
) / self.filename
@property
def default_data(self) -> Any:
"""Default deserialized content to use if the file doesn't exist."""
raise NotImplementedError()
return Path(
os.environ.get("MIRAGE_DATA_DIR") or
self.backend.appdirs.user_data_dir,
) / self.filename
def deserialized(self, data: str) -> Tuple[Any, bool]:
"""Return parsed data from file text and whether to call `save()`."""
return (data, False)
def serialized(self) -> str:
"""Return text from `UserFile.data` that can be written to disk."""
raise NotImplementedError()
def save(self) -> None:
"""Inform the disk writer coroutine that the data has changed."""
self._need_write = True
async def set_data(self, data: Any) -> None:
"""Set `data` and call `save()`, conveniance method for QML."""
self.data = data
self.save()
async def _start_reader(self) -> None:
"""Disk reader coroutine, watches for file changes to update `data`."""
while not self.path.exists():
await asyncio.sleep(1)
async for changes in awatch(self.path):
ignored = 0
for change in changes:
if change[0] in (Change.added, Change.modified):
if self._need_write or self._wrote:
self._wrote = False
ignored += 1
continue
async with aiofiles.open(self.path) as file:
self.data, save = self.deserialized(await file.read())
if save:
self.save()
elif change[0] == Change.deleted:
self._wrote = False
self.data = self.default_data
self._need_write = self.create_missing
if changes and ignored < len(changes):
UserFileChanged(type(self), self.data)
async def default_data(self):
"""Default content if the file doesn't exist."""
return ""
async def read(self):
"""Return future, existing or default content for the file on disk."""
if self._to_write is not None:
return self._to_write
try:
return self.path.read_text()
except FileNotFoundError:
default = await self.default_data()
if self.create_missing:
await self.write(default)
return default
async def write(self, data) -> None:
"""Request for the file to be written/updated with data."""
self._to_write = data
async def _write_loop(self) -> None:
"""Write/update file on disk with a 1 second cooldown."""
async def _start_writer(self) -> None:
"""Disk writer coroutine, update the file with a 1 second cooldown."""
self.path.parent.mkdir(parents=True, exist_ok=True)
while True:
await asyncio.sleep(1)
if self._to_write is None:
continue
if not self.create_missing and not self.path.exists():
continue
async with atomic_write(self.path) as (new, done):
await new.write(self._to_write)
done()
self._to_write = None
if self._need_write:
async with atomic_write(self.path) as (new, done):
await new.write(self.serialized())
done()
self._need_write = False
self._wrote = True
@dataclass
class JSONDataFile(DataFile):
"""Represent a user data file in the JSON format."""
class ConfigFile(UserFile):
"""A file that goes in the configuration directory, e.g. ~/.config/app."""
_data: Optional[Dict[str, Any]] = field(init=False, default=None)
@property
def path(self) -> Path:
return Path(
os.environ.get("MIRAGE_CONFIG_DIR") or
self.backend.appdirs.user_config_dir,
) / self.filename
def __getitem__(self, key: str) -> Any:
if self._data is None:
raise RuntimeError(f"{self}: read() hasn't been called yet")
@dataclass
class UserDataFile(UserFile):
"""A file that goes in the user data directory, e.g. ~/.local/share/app."""
return self._data[key]
@property
def path(self) -> Path:
return Path(
os.environ.get("MIRAGE_DATA_DIR") or
self.backend.appdirs.user_data_dir,
) / self.filename
async def default_data(self) -> JsonData:
@dataclass
class MappingFile(MutableMapping, UserFile):
"""A file manipulable like a dict. `data` must be a mutable mapping."""
def __getitem__(self, key: Any) -> Any:
return self.data[key]
def __setitem__(self, key: Any, value: Any) -> None:
self.data[key] = value
def __delitem__(self, key: Any) -> None:
del self.data[key]
def __iter__(self) -> Iterator:
return iter(self.data)
def __len__(self) -> int:
return len(self.data)
@dataclass
class JSONFile(MappingFile):
"""A file stored on disk in the JSON format."""
@property
def default_data(self) -> dict:
return {}
async def read(self) -> JsonData:
"""Return future, existing or default content for the file on disk.
def deserialized(self, data: str) -> Tuple[dict, bool]:
"""Return parsed data from file text and whether to call `save()`.
If the file has missing keys, the missing data will be merged and
written to disk before returning.
If `create_missing` is `True` and the file doesn't exist, it will be
created.
If the file has missing keys, the missing data will be merged to the
returned dict and the second tuple item will be `True`.
"""
if self._to_write is not None:
return json.loads(self._to_write)
try:
data = json.loads(self.path.read_text())
except FileNotFoundError:
if not self.create_missing:
data = await self.default_data()
self._data = data
return data
data = {}
loaded = json.loads(data)
except json.JSONDecodeError:
data = {}
loaded = {}
all_data = await self.default_data()
dict_update_recursive(all_data, data)
if data != all_data:
await self.write(all_data)
self._data = all_data
return all_data
all_data = self.default_data.copy()
dict_update_recursive(all_data, loaded)
return (all_data, loaded != all_data)
async def write(self, data: JsonData) -> None:
js = json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True)
await super().write(js)
def serialized(self) -> str:
data = self.data
return json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True)
@dataclass
class Accounts(JSONDataFile):
"""Config file for saved matrix accounts: user ID, access tokens, etc."""
is_config = True
class Accounts(ConfigFile, JSONFile):
"""Config file for saved matrix accounts: user ID, access tokens, etc"""
filename: str = "accounts.json"
async def any_saved(self) -> bool:
"""Return whether there are any accounts saved on disk."""
return bool(await self.read())
"""Return for QML whether there are any accounts saved on disk."""
return bool(self.data)
async def add(self, user_id: str) -> None:
@ -189,12 +221,10 @@ class Accounts(JSONDataFile):
the corresponding `MatrixClient` in `backend.clients`.
"""
client = self.backend.clients[user_id]
saved = await self.read()
account = self.backend.models["accounts"][user_id]
client = self.backend.clients[user_id]
account = self.backend.models["accounts"][user_id]
await self.write({
**saved,
self.update({
client.user_id: {
"homeserver": client.homeserver,
"token": client.access_token,
@ -205,9 +235,10 @@ class Accounts(JSONDataFile):
"order": account.order,
},
})
self.save()
async def update(
async def set(
self,
user_id: str,
enabled: Optional[str] = None,
@ -217,45 +248,39 @@ class Accounts(JSONDataFile):
) -> None:
"""Update an account if found in the config file and write to disk."""
saved = await self.read()
if user_id not in saved:
if user_id not in self:
return
if enabled is not None:
saved[user_id]["enabled"] = enabled
self[user_id]["enabled"] = enabled
if presence is not None:
saved[user_id]["presence"] = presence
self[user_id]["presence"] = presence
if order is not None:
saved[user_id]["order"] = order
self[user_id]["order"] = order
if status_msg is not None:
saved[user_id]["status_msg"] = status_msg
self[user_id]["status_msg"] = status_msg
await self.write({**saved})
self.save()
async def delete(self, user_id: str) -> None:
async def forget(self, user_id: str) -> None:
"""Delete an account from the config and write it on disk."""
await self.write({
uid: info
for uid, info in (await self.read()).items() if uid != user_id
})
self.pop(user_id, None)
self.save()
@dataclass
class UISettings(JSONDataFile):
"""Config file for QML interface settings and keybindings."""
is_config = True
class Settings(ConfigFile, JSONFile):
"""General config file for UI and backend settings"""
filename: str = "settings.json"
async def default_data(self) -> JsonData:
@property
def default_data(self) -> dict:
def ctrl_or_osx_ctrl() -> str:
# Meta in Qt corresponds to Ctrl on OSX
return "Meta" if platform.system() == "Darwin" else "Ctrl"
@ -305,7 +330,6 @@ class UISettings(JSONDataFile):
"keys": {
"startPythonDebugger": ["Alt+Shift+D"],
"toggleDebugConsole": ["Alt+Shift+C", "F1"],
"reloadConfig": ["Alt+Shift+R"],
"zoomIn": ["Ctrl++"],
"zoomOut": ["Ctrl+-"],
@ -423,42 +447,58 @@ class UISettings(JSONDataFile):
},
}
def deserialized(self, data: str) -> Tuple[dict, bool]:
dict_data, save = super().deserialized(data)
if "theme" in self and self["theme"] != dict_data["theme"]:
self.backend.theme = Theme(self.backend, dict_data["theme"])
UserFileChanged(Theme, self.backend.theme.data)
return (dict_data, save)
@dataclass
class UIState(JSONDataFile):
"""File to save and restore the state of the QML interface."""
class UIState(UserDataFile, JSONFile):
"""File used to save and restore the state of QML components."""
filename: str = "state.json"
async def default_data(self) -> JsonData:
@property
def default_data(self) -> dict:
return {
"collapseAccounts": {},
"page": "Pages/Default.qml",
"pageProperties": {},
}
def deserialized(self, data: str) -> Tuple[dict, bool]:
dict_data, save = super().deserialized(data)
for user_id, do in dict_data["collapseAccounts"].items():
self.backend.models["all_rooms"].set_account_collapse(user_id, do)
return (dict_data, save)
@dataclass
class History(JSONDataFile):
class History(UserDataFile, JSONFile):
"""File to save and restore lines typed by the user in QML components."""
filename: str = "history.json"
async def default_data(self) -> JsonData:
@property
def default_data(self) -> dict:
return {"console": []}
@dataclass
class Theme(DataFile):
class Theme(UserDataFile):
"""A theme file defining the look of QML components."""
# Since it currently breaks at every update and the file format will be
# changed later, don't copy the theme to user data dir if it doesn't exist.
create_missing = False
@property
def path(self) -> Path:
data_dir = Path(
@ -467,19 +507,17 @@ class Theme(DataFile):
)
return data_dir / "themes" / self.filename
async def default_data(self) -> str:
@property
def default_data(self) -> str:
path = f"src/themes/{self.filename}"
try:
byte_content = pyotherside.qrc_get_file_contents(path)
except ValueError:
# App was compiled without QRC
async with aiofiles.open(path) as file:
return await file.read()
return convert_to_qml(Path(path).read_text())
else:
return byte_content.decode()
return convert_to_qml(byte_content.decode())
async def read(self) -> str:
return convert_to_qml(await super().read())
def deserialized(self, data: str) -> Tuple[str, bool]:
return (convert_to_qml(data), False)

View File

@ -40,12 +40,6 @@ Rectangle {
py.callCoro("get_theme_dir", [], Qt.openUrlExternally)
}
HMenuItem {
icon.name: "reload-config-files"
text: qsTr("Reload config & theme")
onTriggered: mainUI.reloadSettings()
}
HMenuItem {
icon.name: "debug"
text: qsTr("Developer console")

View File

@ -41,7 +41,7 @@ HFlickableColumnPage {
py.callCoro(
rememberAccount.checked ?
"saved_accounts.add":
"saved_accounts.delete",
"saved_accounts.forget",
[receivedUserId]
)

View File

@ -65,6 +65,19 @@ QtObject {
py.showError(type, traceback, "", message)
}
function onUserFileChanged(type, newData) {
if (type === "Theme") {
window.theme = Qt.createQmlObject(newData, window, "theme")
utils.theme = window.theme
return
}
type === "Settings" ? window.settings = newData :
type === "UIState" ? window.uiState = newData :
type === "History" ? window.history = newData :
null
}
function onModelItemSet(syncId, indexThen, indexNow, changedFields) {
const model = ModelStore.get(syncId)

View File

@ -47,23 +47,9 @@ Python {
call("BRIDGE.cancel_coro", [uuid])
}
function saveConfig(backend_attribute, data, callback=null) {
if (! py.ready) { return } // config not loaded yet
return callCoro(backend_attribute + ".write", [data], callback)
}
function loadSettings(callback=null) {
const func = "load_settings"
return callCoro(func, [], ([settings, uiState, history, theme]) => {
window.settings = settings
window.uiState = uiState
window.history = history
window.theme = Qt.createQmlObject(theme, window, "theme")
utils.theme = window.theme
if (callback) { callback(settings, uiState, theme) }
})
function saveConfig(backend_attribute, data) {
if (! py.ready) { return } // config not done loading yet
callCoro(backend_attribute + ".set_data", [data])
}
function showError(type, traceback, sourceIndication="", message="") {

View File

@ -22,7 +22,13 @@ PythonBridge {
addImportPath("qrc:/src")
importNames("backend.qml_bridge", ["BRIDGE"], () => {
loadSettings(() => {
callCoro("get_settings", [], ([settings, state, hist, theme]) => {
window.settings = settings
window.uiState = state
window.history = hist
window.theme = Qt.createQmlObject(theme, window, "theme")
utils.theme = window.theme
callCoro("saved_accounts.any_saved", [], any => {
if (any) { callCoro("load_saved_accounts", []) }

View File

@ -28,41 +28,18 @@ Item {
readonly property alias debugConsole: debugConsole
readonly property alias mainPane: mainPane
readonly property alias pageLoader: pageLoader
readonly property alias pressAnimation: pressAnimation
readonly property alias fontMetrics: fontMetrics
readonly property alias idleManager: idleManager
function reloadSettings() {
py.loadSettings(() => {
image.reload()
mainUI.pressAnimation.start()
})
}
focus: true
Component.onCompleted: window.mainUI = mainUI
SequentialAnimation {
id: pressAnimation
HNumberAnimation {
target: mainUI; property: "scale"; from: 1.0; to: 0.9
}
HNumberAnimation {
target: mainUI; property: "scale"; from: 0.9; to: 1.0
}
}
HShortcut {
sequences: window.settings.keys.startPythonDebugger
onActivated: py.call("BRIDGE.pdb")
}
HShortcut {
sequences: window.settings.keys.reloadConfig
onActivated: reloadSettings()
}
HShortcut {
sequences: window.settings.keys.zoomIn
onActivated: {

View File

@ -88,7 +88,7 @@ ApplicationWindow {
// NOTE: For JS object variables, the corresponding method to notify
// key/value changes must be called manually, e.g. settingsChanged().
onSettingsChanged: py.saveConfig("ui_settings", settings)
onSettingsChanged: py.saveConfig("settings", settings)
onUiStateChanged: py.saveConfig("ui_state", uiState)
onHistoryChanged: py.saveConfig("history", history)