From 04af1e3fce82c65cd837946fd0a94005c17659d9 Mon Sep 17 00:00:00 2001 From: Neui Date: Sat, 10 Oct 2020 01:08:52 +0200 Subject: [PATCH 1/2] Add class to easily implement DBus Interfaces In GLib, there is an Client Proxy you can use to access signals and properties from an DBus interface, but nothing similar for the Server-Side without generating code. So this lays out an class implementers subclass, specify some metadata and implement the methods that the interfaces uses. --- GTG/core/dbus/__init__.py | 0 GTG/core/dbus/dbus.py | 176 ++++++++++++++++++++++++++++++++++++++ GTG/core/meson.build | 6 ++ 3 files changed, 182 insertions(+) create mode 100644 GTG/core/dbus/__init__.py create mode 100644 GTG/core/dbus/dbus.py diff --git a/GTG/core/dbus/__init__.py b/GTG/core/dbus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/GTG/core/dbus/dbus.py b/GTG/core/dbus/dbus.py new file mode 100644 index 0000000000..9832178c1f --- /dev/null +++ b/GTG/core/dbus/dbus.py @@ -0,0 +1,176 @@ +from gi.repository import GLib, Gio +import logging +import os +import traceback + + +log = logging.getLogger(__name__) + + +def _get_installed_xml_from_interface_name(interface_name: str): + """Return an DBus interface XML from system installed folders""" + raw_paths = GLib.get_system_data_dirs() + raw_paths.append(GLib.get_user_data_dir()) + for raw_path in raw_paths: + full_path = os.path.join(raw_path, 'dbus-1', 'interfaces', + interface_name + ".xml") + try: + with open(full_path, 'rt') as f: + log.debug(f"Found file {full_path}") + return f.read() + except OSError: + pass + return None + + +def _get_internal_xml_from_interface_name(interface_name: str): + """Return an DBus interface XML from internal data folders""" + log.debug("TODO: Implement this later") + pass + + +def get_xml_from_interface_name(interface_name: str, + use_internal: bool = True, + use_system: bool = True): + """ + Return an DBus interface XML from either provided by the system (distro) + or internal. + """ + if use_internal: + xml = _get_internal_xml_from_interface_name(interface_name) + if xml is not None: + return xml + if use_system: + xml = _get_installed_xml_from_interface_name(interface_name) + if xml is not None: + return xml + return None + + +class DBusReturnError(Exception): + """ + An error that an DBus interface implementation can throw to indicate + that it should return with that error. + """ + def __init__(self, name, message): + super().__init__(message) + self.name = str(name) + self.message = str(message) + + +class DBusInterfaceImplService(): + INTERFACE_NAME = None + NODE_INFO = None + INTERFACE_INFO = None + _object_id = None + _dbus_connection = None + + def __init__(self, wrapped_object=None): + if wrapped_object is None: + wrapped_object = self + self._wrapped_object = wrapped_object + + def __get_interface_info_from_node_info(self): + for interface in self.NODE_INFO.interfaces: + if interface.name == self.INTERFACE_NAME: + return interface + return None + + def __get_node_info(self): + xml = get_xml_from_interface_name(self.INTERFACE_NAME) + if xml is not None: + return Gio.DBusNodeInfo.new_for_xml(xml) + return None + + def _get_info(self): + if self.NODE_INFO is None and self.INTERFACE_NAME is not None: + self.NODE_INFO = self.__get_node_info() + log.debug(f"Got node info for %r: %r", + self.INTERFACE_NAME, self.NODE_INFO) + if self.NODE_INFO is not None and self.INTERFACE_INFO is None: + self.INTERFACE_INFO = self.__get_interface_info_from_node_info() + log.debug(f"Got interface info for %r: %r", + self.INTERFACE_NAME, self.INTERFACE_INFO) + + def dbus_register(self, dbus_connection, object_path): + """ + Register this implementation on the specified connection and object. + """ + self._get_info() + self._object_id = dbus_connection.register_object( + object_path, + self.INTERFACE_INFO, + self._handle_method_call, + self._handle_get_property, self._handle_set_property) + self._dbus_connection = dbus_connection + return self._object_id + + def dbus_unregister(self): + """Unregister this implementation.""" + if self._dbus_connection is not None and self._object_id is not None: + log.debug("Unregister %r on %r via %r", + self.INTERFACE_NAME, + self._dbus_connection, + self._object_id) + ret = self._dbus_connection.unregister_object(self._object_id) + self._dbus_connection = None + self._object_id = None + if not ret: + log.warn("Unregister for %r failed!", self.INTERFACE_NAME) + return ret + else: + log.warn("Trying to unregister not-registered %r", + self.INTERFACE_NAME) + return False + + def _handle_method_call(self, connection, + sender, object_path, + interface_name, method_name, + parameters, invocation): + dbg = "%s.%s%s on %s by %s" + dbg_args = (interface_name, method_name, parameters, + object_path, sender) + log.debug(f"Called {dbg}", *dbg_args) + + try: + method = self._wrapped_object.__getattribute__(method_name) + except AttributeError as e: + log.debug(f"{dbg} → Internal python exception: %s", *dbg_args, e) + invocation.return_dbus_error("python." + type(e).__name__, str(e)) + return + + try: + ret = method(*parameters.unpack()) + except GLib.Error as e: + log.debug(f"{dbg} → GLib Error: %s", *dbg_args, e) + invocation.return_gerror(e) + except DBusReturnError as e: + log.debug(f"{dbg} → Custom Error: %s: %s", + *dbg_args, e.name, e.message) + invocation.return_dbus_error(e.name, e.message) + except Exception as e: + log.warn(f"{dbg} → Python exception: %s", *dbg_args, e) + traceback.print_exc() + invocation.return_dbus_error("python." + type(e).__name__, str(e)) + else: + if type(ret) != GLib.Variant: + if len(invocation.get_method_info().out_args) == 1: + ret = (ret,) # Make one-value return work as expected + ret = GLib.Variant(self.__get_return_variant(invocation), ret) + log.debug(f"{dbg} → Returning %r", *dbg_args, ret) + invocation.return_value(ret) + + def _handle_get_property(self, *args): + """Get a DBus-available property""" + log.debug(f"TODO: Handle get property: {args}") # Not used currently + return None + + def _handle_set_property(self, *args): + """Set a DBus-available property""" + log.debug(f"TODO: Handle set property: {args}") # Not used currently + return None + + def __get_return_variant(self, invocation): + """Get Variant Type string that should be returned for a DBus method""" + info = invocation.get_method_info() + return '(' + "".join([arg.signature for arg in info.out_args]) + ')' diff --git a/GTG/core/meson.build b/GTG/core/meson.build index 3552e9e6f5..935de05695 100644 --- a/GTG/core/meson.build +++ b/GTG/core/meson.build @@ -52,5 +52,11 @@ gtg_core_plugin_sources = [ 'plugins/engine.py', ] +gtg_core_dbus_sources = [ + 'dbus/__init__.py', + 'dbus/dbus.py', +] + python3.install_sources(gtg_core_sources, subdir: 'GTG' / 'core', pure: true) python3.install_sources(gtg_core_plugin_sources, subdir: 'GTG' / 'core' / 'plugins', pure: true) +python3.install_sources(gtg_core_dbus_sources, subdir: 'GTG' / 'core' / 'dbus', pure: true) From f7394b78cc4c03015830370aa3c19c2f69f69a2b Mon Sep 17 00:00:00 2001 From: Neui Date: Sat, 10 Oct 2020 01:12:10 +0200 Subject: [PATCH 2/2] Add & implement org.gnome.GTG.Tasks DBus interface Introducing org.gnome.GTG.Tasks, an DBus interface to manage tasks. This is very much inspired by the old "DBus Wrapper", that implemented org.gnome.GTGTasks (I think), but this is in some ways different: * Do actions in batches * Only handle tasks, no UI stuff (actions can replace some) --- GTG/core/dbus/tasks.py | 355 ++++++++++++++++++++++++++++++ GTG/core/meson.build | 1 + GTG/gtk/application.py | 8 + data/dbus/meson.build | 8 + data/dbus/org.gnome.GTG.Tasks.xml | 261 ++++++++++++++++++++++ data/meson.build | 1 + meson.build | 1 + 7 files changed, 635 insertions(+) create mode 100644 GTG/core/dbus/tasks.py create mode 100644 data/dbus/meson.build create mode 100644 data/dbus/org.gnome.GTG.Tasks.xml diff --git a/GTG/core/dbus/tasks.py b/GTG/core/dbus/tasks.py new file mode 100644 index 0000000000..d347bd743d --- /dev/null +++ b/GTG/core/dbus/tasks.py @@ -0,0 +1,355 @@ + +# Implementation of the org.gnome.GTG.Tasks interface + +import datetime +import logging +import enum +from gi.repository import GLib +from GTG.core.search import parse_search_query, InvalidQuery +from GTG.core.dates import Date +from GTG.core.task import Task +from .dbus import DBusInterfaceImplService, DBusReturnError + + +__all__ = ('DBusImplTasks', 'InvalidTaskDict', 'TaskDict', 'TaskStatus') +log = logging.getLogger(__name__) + + +class TaskStatus(enum.IntEnum): + """Task status for the DBus-Interface.""" + + unknown = 0 + """Unknown status, normally you shouldn't use and see this.""" + active = 1 + """Task is still active (and is open).""" + dismissed = 2 + """Task has been dismissed (and is closed).""" + done = 3 + """Task has been completed (and is closed).""" + + @classmethod + def status_to_enum(cls, status: str): + _status_to_enum_map = { + Task.STA_ACTIVE: cls.active, + Task.STA_DISMISSED: cls.dismissed, + Task.STA_DONE: cls.done, + } + return _status_to_enum_map.get(status, cls.unknown) + + @classmethod + def enum_to_status(cls, e): + _enum_to_status_map = { + cls.active: Task.STA_ACTIVE, + cls.dismissed: Task.STA_DISMISSED, + cls.done: Task.STA_DONE, + } + return _enum_to_status_map.get(e, '') + + +class TaskDict(dict): + """Helper class for managing a task dictionary.""" + + @classmethod + def from_task(cls, task: Task): + """ + Converts the specified task to an task dictionary. + """ + d = TaskDict() + d["id"] = task.get_id() + d["status"] = TaskStatus.status_to_enum(task.get_status()) + d["title"] = task.get_title() + d["duedate"] = _date_to_string(task.get_due_date()) + d["startdate"] = _date_to_string(task.get_start_date()) + d["donedate"] = _date_to_string(task.get_closed_date()) + d["tags"] = task.get_tags_name() + d["text"] = task.get_text() + d["children"] = task.get_children() + d["parents"] = task.get_parents() + return d + + _to_variant_type = { + "id": 's', + "status": 'i', + "title": 's', + "duedate": 's', + "startdate": 's', + "donedate": 's', + "tags": 'as', + "text": 's', + "children": 'as', + "parents": 'as', + } + + def to_variant(self) -> dict: + """ + Convert an task dict to a variant dict to be submitted over DBus. + """ + d = dict(self) + for name, vtype in self._to_variant_type.items(): + if name in d: + d[name] = GLib.Variant(vtype, d[name]) + return d + # return GLib.Variant("a{sv}", d) breaks GLib.Variant('aa{sv}', [dv]) + + +def _task_to_variant(task: Task) -> dict: + """ + Convert an task object to a variant dict to be submitted over DBus. + """ + return TaskDict.from_task(task).to_variant() + + +def _variant_to_task_dict(task_variant) -> TaskDict: + """ + Convert an variant dict to a task dict. + """ + return TaskDict(task_variant.unpack()) + + +def _date_to_string(date: Date): + """ + Convert a gtg date to either an english fuzzy string or to ISO format. + """ + if date == Date.now(): + return "now" + if date == Date.no_date(): + return "" + if date == Date.soon(): + return "soon" + if date == Date.someday(): + return "someday" + return date.isoformat() # Uses the wrapped python date object + + +_string_to_date_map = { + "": Date.no_date(), + "now": Date.now(), + "soon": Date.soon(), + "someday": Date.someday(), +} + + +def _string_to_date(sdate: str) -> Date: + """ + Convert string to the correct a gtg date without localization. + """ + try: + return _string_to_date_map[sdate] + except KeyError: + try: + return Date(datetime.date.fromisoformat(sdate)) + except ValueError: + raise DBusReturnError("gtg.InvalidDateFormat", + f"Invalid date format '{sdate}'") + + +class InvalidTaskDict(DBusReturnError): + """ + Return DBus Error about an invalid type for a task dictionary. + """ + def __init__(self, what, got, expected): + got = got.__name__ + expected = expected.__name__ + super().__init__("gtg.InvalidTaskDict", + f"{what} is a {got}, but expected {expected}") + + +class DBusImplTasks(DBusInterfaceImplService): + INTERFACE_NAME = 'org.gnome.GTG.Tasks' + + def __init__(self, req): + super().__init__() + self.req = req + + tree = req.get_main_view() + # TODO: Register signals + # tree.register_cllbck('node-added', lambda tid, _: + # self.TaskAdded(tid)) + # tree.register_cllbck('node-modified', lambda tid, _: + # self.TaskModified(tid)) + # tree.register_cllbck('node-deleted', lambda tid, _: + # self.TaskDeleted(tid)) + + def _get_task(self, tid): + """ + Return task from a tid, otherwise return an not-found DBus error. + """ + task = self.req.get_task(tid) + if task is None: + raise DBusReturnError("gtg.TaskNotFound", + f"Task not found by id '{tid}'") + return task + + def GetTasks(self, tids: list) -> list: + log.debug(f"Doing GetTasks({tids})") + return [_task_to_variant(self._get_task(tid)) for tid in tids] + + def GetActiveTaskIds(self) -> list: + log.debug(f"Doing GetActiveTaskIds()") + return self.GetTaskIdsFiltered(['active', 'workable']) + + def GetActiveTasks(self) -> list: + log.debug(f"Doing GetActiveTasks()") + return self.GetTasks(self.GetActiveTaskIds()) + + def GetTaskIdsFiltered(self, filters: list) -> list: + log.debug(f"Doing GetTasksFiltered({filters})") + tree = self.req.get_tasks_tree().get_basetree() + view = tree.get_viewtree(name=None) # Anonymous viewtree + last_index = len(filters) - 1 + for i, filter in enumerate(filters): + is_last = i == last_index + if filter[0] == '!': + view.apply_filter(filter[1:], parameters={'negate': 1}, + refresh=is_last) + else: + view.apply_filter(filter, refresh=is_last) + return list(view.get_all_nodes()) + + def GetTasksFiltered(self, filters: list) -> list: + log.debug(f"Doing GetTasksFiltered({filters})") + return self.GetTasks(self.GetTaskIdsFiltered(filters)) + + def SearchTaskIds(self, query: str) -> list: + log.debug(f"Doing SearchTaskIds({query})") + tree = self.req.get_tasks_tree().get_basetree() + view = tree.get_viewtree() + try: + search = parse_search_query(query) + view.apply_filter('search', parameters=search) + tasks = view.get_all_nodes() + if tasks: + return tasks + except InvalidQuery as e: + raise DBusReturnError("gtg.InvalidQuery", + f"Invalid Query '{query}': {e}") + return [] + + def SearchTasks(self, query: str) -> list: + log.debug(f"Doing SearchTasks({query})") + return self.GetTasks(self.SearchTaskIds(query)) + + def HasTasks(self, tids: list) -> dict: + log.debug(f"Doing HasTasks({tids})") + return {tid: self.req.has_task(tid) for tid in tids} + + def DeleteTasks(self, tids: list) -> dict: + log.debug(f"Doing DeleteTasks({tids})") + d = {} + for tid in tids: + d[tid] = self.req.has_task(tid) + if d[tid]: # Task exists, so let's delete it + self.req.delete_task(tid) + return d + + def NewTasks(self, tasks: list) -> list: + log.debug(f"Doing NewTasks({tasks})") + r = [] + for new_task_dict in tasks: + self._verify_task_dict(new_task_dict) + new_task = self.req.new_task() + self._modify_task(new_task, new_task_dict) + r.append(_task_to_variant(new_task)) + return r + + def ModifyTasks(self, patches: list) -> list: + log.debug(f"Doing ModifyTasks({patches})") + r = [] + for patch in patches: + if "id" not in patch: + raise DBusReturnError("gtg.MissingTaskId", + "No 'id' in task dict") + self._verify_task_dict(patch) + task = self._get_task(patch["id"]) + patched_task = self._modify_task(task, patch) + r.append(_task_to_variant(patched_task)) + return r + + def _modify_task(self, task: Task, patch: dict) -> Task: + """Modify a single task and return it""" + if "title" in patch: + task.set_title(patch["title"]) + if "text" in patch: + task.set_text(patch["text"]) + if "duedate" in patch: + task.set_due_date(_string_to_date(patch["duedate"])) + if "startdate" in patch: + task.set_start_date(_string_to_date(patch["startdate"])) + if "status" in patch: + task.set_status(TaskStatus.enum_to_status(patch["status"])) + if "donedate" in patch: + old_status = task.get_status() + donedate = _string_to_date(patch["donedate"]) + task.set_status(old_status, donedate=donedate) + if "tags" in patch: + old_tags = set(task.get_tags_name()) + new_tags = set(patch["tags"]) + common_tags = old_tags & new_tags + for removed_tag in old_tags - common_tags: + task.remove_tag(removed_tag) + for added_tag in new_tags - common_tags: + task.add_tag(added_tag) + if "children" in patch: + log.debug("TODO: Implement patching subtask list") # TODO + if "parents" in patch: + log.debug("TODO: Implement patching parents") # TODO + return task + + def _verify_task_dict_types(self, td: dict): + """ + Check if an task dict has the correct types. + """ + # Reusing TaskDict._to_variant_type because it already contains the + # types it uses for serializing + for key, vartype in TaskDict._to_variant_type.items(): + if key not in td: + continue + + if vartype == 'i': + expected_type = int + elif vartype == 's': + expected_type = str + elif vartype[0] == 'a': + expected_type = list + else: + raise RuntimeError("Unknown vartype") + + if type(td[key]) is not expected_type: + raise InvalidTaskDict(f"'{key}'", type(td[key]), expected_type) + + if vartype[0] != 'a': + continue + + if vartype[1] == 's': + expected_type = str + else: + log.debug("Unknown vartype, can't verify children: %r", + vartype) + continue + for elem in td[key]: + if type(elem) is not expected_type: + raise InvalidTaskDict(f"'{key}' element", + type(elem), expected_type) + + def _verify_task_dict(self, td: dict): + """ + Verify the types and whenever the values are valid for a task dict. + """ + self._verify_task_dict_types(td) + if "startdate" in td: + date = _string_to_date(td["startdate"]) + if date.is_fuzzy() and date != Date.no_date(): + raise DBusReturnError("gtg.InvalidStartDate", + f"Invalid start date '{td['startdate']}'") + + if "donedate" in td: # TODO: What about status? + date = _string_to_date(td["donedate"]) + if date.is_fuzzy() and date != Date.no_date(): + raise DBusReturnError("gtg.InvalidDoneDate", + f"Invalid done date '{td['donedate']}'") + + if "status" in td: + status = TaskStatus.enum_to_status(td["status"]) + if status == '': + raise DBusReturnError("gtg.InvalidStatus", + f"Invalid status {td['status']}") diff --git a/GTG/core/meson.build b/GTG/core/meson.build index 935de05695..912159c023 100644 --- a/GTG/core/meson.build +++ b/GTG/core/meson.build @@ -55,6 +55,7 @@ gtg_core_plugin_sources = [ gtg_core_dbus_sources = [ 'dbus/__init__.py', 'dbus/dbus.py', + 'dbus/tasks.py', ] python3.install_sources(gtg_core_sources, subdir: 'GTG' / 'core', pure: true) diff --git a/GTG/gtk/application.py b/GTG/gtk/application.py index 9f4c435ae7..048644afa9 100644 --- a/GTG/gtk/application.py +++ b/GTG/gtk/application.py @@ -40,6 +40,7 @@ from GTG.gtk.backends import BackendsDialog from GTG.gtk.browser.tag_editor import TagEditor from GTG.core.timer import Timer +from GTG.core.dbus.tasks import DBusImplTasks log = logging.getLogger(__name__) @@ -117,6 +118,10 @@ def do_startup(self): self.init_style() + self.dbus_tasks = DBusImplTasks(self.req) + self.dbus_tasks.dbus_register(self.get_dbus_connection(), + self.get_dbus_object_path()) + def do_activate(self): """Callback when launched from the desktop.""" @@ -564,6 +569,9 @@ def do_shutdown(self): # Save data and shutdown datastore backends self.req.save_datastore(quit=True) + if self.dbus_tasks: + self.dbus_tasks.dbus_unregister() + Gtk.Application.do_shutdown(self) # -------------------------------------------------------------------------- diff --git a/data/dbus/meson.build b/data/dbus/meson.build new file mode 100644 index 0000000000..b45999c18a --- /dev/null +++ b/data/dbus/meson.build @@ -0,0 +1,8 @@ +configure_file( + input: rdnn_name + '.Tasks.xml', + output: rdnn_name + '.Tasks.xml', + copy: true, + install: true, + install_dir: dbusinterfacesdir +) + diff --git a/data/dbus/org.gnome.GTG.Tasks.xml b/data/dbus/org.gnome.GTG.Tasks.xml new file mode 100644 index 0000000000..d6d3ce1eb7 --- /dev/null +++ b/data/dbus/org.gnome.GTG.Tasks.xml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/meson.build b/data/meson.build index b93f412abb..ce27329592 100644 --- a/data/meson.build +++ b/data/meson.build @@ -34,3 +34,4 @@ configure_file( ) subdir('icons') +subdir('dbus') diff --git a/meson.build b/meson.build index 4035f5c436..66b42a3806 100644 --- a/meson.build +++ b/meson.build @@ -20,6 +20,7 @@ datadir = prefix / get_option('datadir') appdatadir = datadir / 'metainfo' desktopdir = datadir / 'applications' dbusservicedir = datadir / 'dbus-1' / 'services' +dbusinterfacesdir = datadir / 'dbus-1' / 'interfaces' icondir = datadir / 'icons' pythondir = python3.get_path('purelib') rdnn_name = 'org.gnome.GTG'