diff --git a/requirements.txt b/requirements.txt index 08c37022..366e23cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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" diff --git a/src/backend/backend.py b/src/backend/backend.py index 83bfb54c..b03ff994 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -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: diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index 849cf2d6..8c41510c 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -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, {}), ) diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index bb3fb52a..394f02a4 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -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, diff --git a/src/backend/pyotherside_events.py b/src/backend/pyotherside_events.py index 56860016..6f283db9 100644 --- a/src/backend/pyotherside_events.py +++ b/src/backend/pyotherside_events.py @@ -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.""" diff --git a/src/backend/user_files.py b/src/backend/user_files.py index 6799de28..373eebb6 100644 --- a/src/backend/user_files.py +++ b/src/backend/user_files.py @@ -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) diff --git a/src/gui/MainPane/TopBar.qml b/src/gui/MainPane/TopBar.qml index e59f4dae..314eaa26 100644 --- a/src/gui/MainPane/TopBar.qml +++ b/src/gui/MainPane/TopBar.qml @@ -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") diff --git a/src/gui/Pages/AddAccount/SignInBase.qml b/src/gui/Pages/AddAccount/SignInBase.qml index b489d126..91901c9d 100644 --- a/src/gui/Pages/AddAccount/SignInBase.qml +++ b/src/gui/Pages/AddAccount/SignInBase.qml @@ -41,7 +41,7 @@ HFlickableColumnPage { py.callCoro( rememberAccount.checked ? "saved_accounts.add": - "saved_accounts.delete", + "saved_accounts.forget", [receivedUserId] ) diff --git a/src/gui/PythonBridge/EventHandlers.qml b/src/gui/PythonBridge/EventHandlers.qml index 2349836b..15835157 100644 --- a/src/gui/PythonBridge/EventHandlers.qml +++ b/src/gui/PythonBridge/EventHandlers.qml @@ -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) diff --git a/src/gui/PythonBridge/PythonBridge.qml b/src/gui/PythonBridge/PythonBridge.qml index 99a9e3e1..bfc08685 100644 --- a/src/gui/PythonBridge/PythonBridge.qml +++ b/src/gui/PythonBridge/PythonBridge.qml @@ -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="") { diff --git a/src/gui/PythonBridge/PythonRootBridge.qml b/src/gui/PythonBridge/PythonRootBridge.qml index 8281b51c..9296a2c0 100644 --- a/src/gui/PythonBridge/PythonRootBridge.qml +++ b/src/gui/PythonBridge/PythonRootBridge.qml @@ -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", []) } diff --git a/src/gui/UI.qml b/src/gui/UI.qml index a62f2e0f..f518ca7b 100644 --- a/src/gui/UI.qml +++ b/src/gui/UI.qml @@ -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: { diff --git a/src/gui/Window.qml b/src/gui/Window.qml index faebbb11..eb1ea7bd 100644 --- a/src/gui/Window.qml +++ b/src/gui/Window.qml @@ -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)