diff --git a/src/hamster-cli.py b/src/hamster-cli.py index 9c6786787..b666a720e 100644 --- a/src/hamster-cli.py +++ b/src/hamster-cli.py @@ -25,6 +25,7 @@ import sys, os import argparse import re +import signal import gi gi.require_version('Gdk', '3.0') # noqa: E402 @@ -33,11 +34,10 @@ from gi.repository import Gdk as gdk from gi.repository import Gtk as gtk from gi.repository import Gio as gio -from gi.repository import GLib as glib import hamster -from hamster import client, reports +from hamster import reports from hamster import logger as hamster_logger from hamster.about import About from hamster.edit_activity import CustomFactController @@ -45,6 +45,7 @@ from hamster.preferences import PreferencesEditor from hamster.lib import default_logger, stuff from hamster.lib import datetime as dt +from hamster.lib import i18n from hamster.lib.fact import Fact @@ -95,7 +96,10 @@ def fact_dict(fact_data, with_date): return fact -class Hamster(gtk.Application): +# keep both HamsterGUI and HamsterCLI in this file because they +# should not be reused elsewhere. + +class HamsterGUI(gtk.Application): """Hamster gui. Actions should eventually be accessible via Gio.DBusActionGroup @@ -106,7 +110,7 @@ class Hamster(gtk.Application): is still the stable recommended way to show windows for now. """ - def __init__(self): + def __init__(self, storage): # inactivity_timeout: How long (ms) the service should stay alive # after all windows have been closed. gtk.Application.__init__(self, @@ -114,6 +118,8 @@ def __init__(self): #inactivity_timeout=10000, register_session=True) + self.storage = storage + self.about_controller = None # 'about' window controller self.fact_controller = None # fact window controller self.overview_controller = None # overview window controller @@ -176,17 +182,18 @@ def _open_window(self, name, data=None): logger.warning("Fact controller already active. Please close first.") else: fact_id = data.get_int32() if data else None - self.fact_controller = CustomFactController(name, fact_id=fact_id) + self.fact_controller = CustomFactController(name, self.storage, + fact_id=fact_id) logger.debug("new CustomFactController") controller = self.fact_controller elif name == "overview": if not self.overview_controller: - self.overview_controller = Overview() + self.overview_controller = Overview(self.storage) logger.debug("new Overview") controller = self.overview_controller elif name == "preferences": if not self.preferences_controller: - self.preferences_controller = PreferencesEditor() + self.preferences_controller = PreferencesEditor(self.storage) logger.debug("new PreferencesEditor") controller = self.preferences_controller @@ -222,20 +229,91 @@ def present_fact_controller(self, action, fact_id=0): action_data = None # always open dialogs through actions, # both for consistency, and to reduce the paths to test. - app.activate_action(action, action_data) + self.activate_action(action, action_data) -class HamsterCli(object): + +class HamsterCLI(object): """Command line interface.""" def __init__(self): - self.storage = client.Storage() + parser = argparse.ArgumentParser( + description="Time tracking utility", + epilog=usage, + formatter_class=argparse.RawDescriptionHelpFormatter) + + # cf. https://stackoverflow.com/a/28611921/3565696 + parser.add_argument("--log", dest="log_level", + choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'), + default='WARNING', + help="Set the logging level (default: %(default)s)") + parser.add_argument("--no-dbus", dest="no_dbus", action="store_true", + help="(experimental) Direct access to the database. " + "Another process trying to write at the same time is untested yet.") + parser.add_argument("action", nargs="?", default="overview") + parser.add_argument('action_args', nargs=argparse.REMAINDER, default=[]) + + args, unknown_args = parser.parse_known_args() + + # logger for current script + logger.setLevel(args.log_level) + # hamster_logger for the rest + hamster_logger.setLevel(args.log_level) + + if not hamster.installed: + logger.info("Running in devel mode") + + if args.no_dbus: + from hamster.storage.db import Storage + else: + from hamster.dbus.client import Storage + self.storage = Storage() + + if args.action in ("start", "track"): + action = "add" # alias + elif args.action == "prefs": + # for backward compatibility + action = "preferences" + else: + action = args.action + + if action in ("about", "add", "edit", "overview", "preferences"): + if action == "add" and args.action_args: + assert not unknown_args, "unknown options: {}".format(unknown_args) + # directly add fact from arguments + id_ = self.start(*args.action_args) + assert id_ > 0, "failed to add fact" + sys.exit(0) + else: + self.app = HamsterGUI(self.storage) + logger.debug("app instanciated") + self.app.register() + if action == "edit": + assert len(args.action_args) == 1, ( + "edit requires exactly one argument, got {}" + .format(args.action_args)) + id_ = int(args.action_args[0]) + assert id_ > 0, "received non-positive id : {}".format(id_) + action_data = glib.Variant.new_int32(id_) + else: + action_data = None + self.app.activate_action(action, action_data) + run_args = [sys.argv[0]] + unknown_args + logger.debug("run {}".format(run_args)) + status = self.app.run(run_args) + logger.debug("app exited") + sys.exit(status) + elif hasattr(self, action): + getattr(self, action)(*args.action_args) + else: + sys.exit(usage % {'prog': sys.argv[0]}) + def assist(self, *args): assist_command = args[0] if args else "" if assist_command == "start": - hamster_client._activities(sys.argv[-1]) + self._activities(sys.argv[-1]) elif assist_command == "export": formats = "html tsv xml ical".split() chosen = sys.argv[-1] @@ -410,11 +488,15 @@ def version(self): print(hamster.__version__) -if __name__ == '__main__': - from hamster.lib import i18n - i18n.setup_i18n() +# https://docs.python.org/3.7/library/gettext.html#deferred-translations +# mark usage as translatable with +# xgettext ... --keyword=N_ ... + +def N_(message): + return message - usage = _( + +usage = N_( """ Actions: * add [activity [start-time [end-time]]]: Add an activity @@ -450,69 +532,10 @@ def version(self): August 2012. Will check against activity, category, description and tags """) - hamster_client = HamsterCli() - app = Hamster() - logger.debug("app instanciated") - - import signal - signal.signal(signal.SIGINT, signal.SIG_DFL) # gtk3 screws up ctrl+c - - parser = argparse.ArgumentParser( - description="Time tracking utility", - epilog=usage, - formatter_class=argparse.RawDescriptionHelpFormatter) - - # cf. https://stackoverflow.com/a/28611921/3565696 - parser.add_argument("--log", dest="log_level", - choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'), - default='WARNING', - help="Set the logging level (default: %(default)s)") - parser.add_argument("action", nargs="?", default="overview") - parser.add_argument('action_args', nargs=argparse.REMAINDER, default=[]) - - args, unknown_args = parser.parse_known_args() - - # logger for current script - logger.setLevel(args.log_level) - # hamster_logger for the rest - hamster_logger.setLevel(args.log_level) - - if not hamster.installed: - logger.info("Running in devel mode") - - if args.action in ("start", "track"): - action = "add" # alias - elif args.action == "prefs": - # for backward compatibility - action = "preferences" - else: - action = args.action - - if action in ("about", "add", "edit", "overview", "preferences"): - if action == "add" and args.action_args: - assert not unknown_args, "unknown options: {}".format(unknown_args) - # directly add fact from arguments - id_ = hamster_client.start(*args.action_args) - assert id_ > 0, "failed to add fact" - sys.exit(0) - else: - app.register() - if action == "edit": - assert len(args.action_args) == 1, ( - "edit requires exactly one argument, got {}" - .format(args.action_args)) - id_ = int(args.action_args[0]) - assert id_ > 0, "received non-positive id : {}".format(id_) - action_data = glib.Variant.new_int32(id_) - else: - action_data = None - app.activate_action(action, action_data) - run_args = [sys.argv[0]] + unknown_args - logger.debug("run {}".format(run_args)) - status = app.run(run_args) - logger.debug("app exited") - sys.exit(status) - elif hasattr(hamster_client, action): - getattr(hamster_client, action)(*args.action_args) - else: - sys.exit(usage % {'prog': sys.argv[0]}) + +if __name__ == '__main__': + i18n.setup_i18n() + signal.signal(signal.SIGINT, signal.SIG_DFL) # gtk3 screws up ctrl+c + + cli = HamsterCLI() + diff --git a/src/hamster-service.py b/src/hamster-service.py index 4b1e43a71..2eb484779 100644 --- a/src/hamster-service.py +++ b/src/hamster-service.py @@ -15,7 +15,7 @@ from hamster.storage import db from hamster.lib import datetime as dt from hamster.lib import default_logger -from hamster.lib.dbus import ( +from hamster.dbus.utilities import ( DBusMainLoop, fact_signature, from_dbus_date, @@ -38,16 +38,18 @@ quit() -class Storage(db.Storage, dbus.service.Object): +class Storage(dbus.service.Object): __dbus_object_path__ = "/org/gnome/Hamster" def __init__(self, loop): self.bus = dbus.SessionBus() bus_name = dbus.service.BusName("org.gnome.Hamster", bus=self.bus) - dbus.service.Object.__init__(self, bus_name, self.__dbus_object_path__) - db.Storage.__init__(self, unsorted_localized="") + + self.storage = db.Storage(unsorted_localized="") + # append our own fixtures to the database + self.run_fixtures() self.mainloop = loop @@ -57,10 +59,13 @@ def __init__(self, loop): None) self.__monitor.connect("changed", self._on_us_change) + self.storage.connect("tags-changed", self._on_storage_tags_changed) + self.storage.connect("facts-changed", self._on_storage_facts_changed) + self.storage.connect("activities-changed", self._on_storage_activities_changed) + def run_fixtures(self): """we start with an empty database and then populate with default values. This way defaults can be localized!""" - super(Storage, self).run_fixtures() # defaults defaults = [ @@ -72,12 +77,21 @@ def run_fixtures(self): _("Watering flowers"), _("Doing handstands")]) ] - if not self.get_categories(): + if not self.storage.get_categories(): for category, activities in defaults: - cat_id = self.add_category(category) + cat_id = self.storage.add_category(category) for activity in activities: - self.add_activity(activity, cat_id) + self.storage.add_activity(activity, cat_id) + + # wrappers needed because GObject signals always pass their owner + def _on_storage_tags_changed(self, storage): + self.TagsChanged() + + def _on_storage_facts_changed(self, storage): + self.FactsChanged() + def _on_storage_activities_changed(self, storage): + self.ActivitiesChanged() # stop service when we have been updated (will be brought back in next call) # anyway. should make updating simpler @@ -87,30 +101,24 @@ def _on_us_change(self, monitor, gio_file, event_uri, event): self.Quit() @dbus.service.signal("org.gnome.Hamster") - def TagsChanged(self): pass - def tags_changed(self): - self.TagsChanged() + def TagsChanged(self): + logger.info("tags changed") @dbus.service.signal("org.gnome.Hamster") - def FactsChanged(self): pass - def facts_changed(self): - self.FactsChanged() + def FactsChanged(self): + logger.info("facts changed") @dbus.service.signal("org.gnome.Hamster") - def ActivitiesChanged(self): pass - def activities_changed(self): - self.ActivitiesChanged() + def ActivitiesChanged(self): + logger.info("activities changed") + # Fate undecided. Is anybody using that ? Not in hamster anyway, as of 2020-02-27. + # But note the recursive loop. That one has not been fired for a long while... @dbus.service.signal("org.gnome.Hamster") def ToggleCalled(self): pass def toggle_called(self): self.toggle_called() - def dispatch_overwrite(self): - self.TagsChanged() - self.FactsChanged() - self.ActivitiesChanged() - @dbus.service.method("org.gnome.Hamster") def Quit(self): """ @@ -173,7 +181,7 @@ def AddFact(self, fact_str, start_time, end_time, temporary): elif end_time != 0: fact.end_time = dt.datetime.utcfromtimestamp(end_time) - return self.add_fact(fact) + return self.storage.add_fact(fact) @dbus.service.method("org.gnome.Hamster", in_signature='s', out_signature='i') @@ -192,7 +200,7 @@ def AddFactJSON(self, dbus_fact): fact id (int), 0 means failure. """ fact = from_dbus_fact_json(dbus_fact) - return self.add_fact(fact) + return self.storage.add_fact(fact) @dbus.service.method("org.gnome.Hamster", @@ -215,7 +223,7 @@ def CheckFact(self, dbus_fact, dbus_default_day): fact = from_dbus_fact_json(dbus_fact) dd = from_dbus_date(dbus_default_day) try: - self.check_fact(fact, default_day=dd) + self.storage.check_fact(fact, default_day=dd) success = True message = "" except FactError as error: @@ -229,7 +237,7 @@ def CheckFact(self, dbus_fact, dbus_default_day): out_signature=fact_signature) def GetFact(self, fact_id): """Get fact by id. For output format see GetFacts""" - fact = self.get_fact(fact_id) + fact = self.storage.get_fact(fact_id) return to_dbus_fact(fact) @@ -241,7 +249,7 @@ def GetFactJSON(self, fact_id): Return fact in JSON format (cf. to_dbus_fact_json) """ - fact = self.get_fact(fact_id) + fact = self.storage.get_fact(fact_id) return to_dbus_fact_json(fact) @@ -254,7 +262,7 @@ def UpdateFact(self, fact_id, fact, start_time, end_time, temporary): end_time = end_time or None if end_time: end_time = dt.datetime.utcfromtimestamp(end_time) - return self.update_fact(fact_id, fact, start_time, end_time, temporary) + return self.storage.update_fact(fact_id, fact, start_time, end_time, temporary) @dbus.service.method("org.gnome.Hamster", @@ -270,7 +278,7 @@ def UpdateFactJSON(self, fact_id, dbus_fact): int: new id (0 means failure) """ fact = from_dbus_fact_json(dbus_fact) - return self.update_fact(fact_id, fact) + return self.storage.update_fact(fact_id, fact) @dbus.service.method("org.gnome.Hamster", in_signature='i') @@ -279,13 +287,13 @@ def StopTracking(self, end_time): end_time = end_time or None if end_time: end_time = dt.datetime.utcfromtimestamp(end_time) - return self.stop_tracking(end_time) + return self.storage.stop_tracking(end_time) @dbus.service.method("org.gnome.Hamster", in_signature='i') def RemoveFact(self, fact_id): """Remove fact from storage by it's ID""" - return self.remove_fact(fact_id) + return self.storage.remove_fact(fact_id) @dbus.service.method("org.gnome.Hamster", @@ -310,7 +318,7 @@ def GetFacts(self, start_date, end_date, search_terms): if end_date: end = dt.datetime.utcfromtimestamp(end_date).date() - return [to_dbus_fact(fact) for fact in self.get_facts(start, end, search_terms)] + return [to_dbus_fact(fact) for fact in self.storage.get_facts(start, end, search_terms)] @dbus.service.method("org.gnome.Hamster", @@ -332,7 +340,7 @@ def GetFactsJSON(self, dbus_range, search_terms): """ range = from_dbus_range(dbus_range) return [to_dbus_fact_json(fact) - for fact in self.get_facts(range, search_terms=search_terms)] + for fact in self.storage.get_facts(range, search_terms=search_terms)] @dbus.service.method("org.gnome.Hamster", out_signature='a{}'.format(fact_signature)) @@ -342,7 +350,7 @@ def GetTodaysFacts(self): Legacy, to be superceded by GetTodaysFactsJSON at some point. """ - return [to_dbus_fact(fact) for fact in self.get_todays_facts()] + return [to_dbus_fact(fact) for fact in self.storage.get_todays_facts()] @dbus.service.method("org.gnome.Hamster", out_signature='as') @@ -351,43 +359,43 @@ def GetTodaysFactsJSON(self): Return an array of facts in JSON format. """ - return [to_dbus_fact_json(fact) for fact in self.get_todays_facts()] + return [to_dbus_fact_json(fact) for fact in self.storage.get_todays_facts()] # categories @dbus.service.method("org.gnome.Hamster", in_signature='s', out_signature = 'i') def AddCategory(self, name): - return self.add_category(name) + return self.storage.add_category(name) @dbus.service.method("org.gnome.Hamster", in_signature='s', out_signature='i') def GetCategoryId(self, category): - return self.get_category_id(category) + return self.storage.get_category_id(category) @dbus.service.method("org.gnome.Hamster", in_signature='is') def UpdateCategory(self, id, name): - self.update_category(id, name) + self.storage.update_category(id, name) @dbus.service.method("org.gnome.Hamster", in_signature='i') def RemoveCategory(self, id): - self.remove_category(id) + self.storage.remove_category(id) @dbus.service.method("org.gnome.Hamster", out_signature='a(is)') def GetCategories(self): - return [(category['id'], category['name']) for category in self.get_categories()] + return [(category['id'], category['name']) for category in self.storage.get_categories()] # activities @dbus.service.method("org.gnome.Hamster", in_signature='si', out_signature = 'i') def AddActivity(self, name, category_id): - return self.add_activity(name, category_id) + return self.storage.add_activity(name, category_id) @dbus.service.method("org.gnome.Hamster", in_signature='isi') def UpdateActivity(self, id, name, category_id): - self.update_activity(id, name, category_id) + self.storage.update_activity(id, name, category_id) @dbus.service.method("org.gnome.Hamster", in_signature='i') def RemoveActivity(self, id): - return self.remove_activity(id) + return self.storage.remove_activity(id) @dbus.service.method("org.gnome.Hamster", in_signature='i', out_signature='a(isis)') def GetCategoryActivities(self, category_id): @@ -395,41 +403,41 @@ def GetCategoryActivities(self, category_id): row['name'], row['category_id'], row['category'] or '') for row in - self.get_category_activities(category_id = category_id)] + self.storage.get_category_activities(category_id = category_id)] @dbus.service.method("org.gnome.Hamster", in_signature='s', out_signature='a(ss)') def GetActivities(self, search = ""): - return [(row['name'], row['category'] or '') for row in self.get_activities(search)] + return [(row['name'], row['category'] or '') for row in self.storage.get_activities(search)] @dbus.service.method("org.gnome.Hamster", in_signature='ii', out_signature = 'b') def ChangeCategory(self, id, category_id): - return self.change_category(id, category_id) + return self.storage.change_category(id, category_id) @dbus.service.method("org.gnome.Hamster", in_signature='sib', out_signature='a{sv}') def GetActivityByName(self, activity, category_id, resurrect = True): category_id = category_id or None if activity: - return dict(self.get_activity_by_name(activity, category_id, resurrect) or {}) + return dict(self.storage.get_activity_by_name(activity, category_id, resurrect) or {}) else: return {} # tags @dbus.service.method("org.gnome.Hamster", in_signature='b', out_signature='a(isb)') def GetTags(self, only_autocomplete): - return [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.get_tags(only_autocomplete)] + return [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.storage.get_tags(only_autocomplete)] @dbus.service.method("org.gnome.Hamster", in_signature='as', out_signature='a(isb)') def GetTagIds(self, tags): - return [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.get_tag_ids(tags)] + return [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.storage.get_tag_ids(tags)] @dbus.service.method("org.gnome.Hamster", in_signature='s') def SetTagsAutocomplete(self, tags): - self.update_autocomplete_tags(tags) + self.storage.update_autocomplete_tags(tags) @dbus.service.method("org.gnome.Hamster", out_signature='s') diff --git a/src/hamster/about.py b/src/hamster/about.py index 9813da28a..28de46acd 100644 --- a/src/hamster/about.py +++ b/src/hamster/about.py @@ -19,17 +19,18 @@ from os.path import join -from hamster.lib.configuration import runtime from gi.repository import Gtk as gtk from gi.repository import Gdk as gdk +import hamster + class About(object): def __init__(self, parent=None): about = gtk.AboutDialog(parent=parent) self.window = about infos = { "program-name" : "Hamster", - "version" : runtime.version, + "version" : hamster.__version__, "comments" : _("Project Hamster — track your time"), "copyright" : _("Copyright © 2007–2010 Toms Bauģis and others"), "website" : "https://github.com/projecthamster/hamster/wiki/", diff --git a/src/hamster/client.py b/src/hamster/client.py deleted file mode 100644 index 736d23249..000000000 --- a/src/hamster/client.py +++ /dev/null @@ -1,284 +0,0 @@ -# - coding: utf-8 - - -# Copyright (C) 2007 Patryk Zawadzki -# Copyright (C) 2007-2009 Toms Baugis - -# This file is part of Project Hamster. - -# Project Hamster is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Project Hamster is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Project Hamster. If not, see . - - -import dbus -import logging -logger = logging.getLogger(__name__) # noqa: E402 -import sys - -from calendar import timegm -from distutils.version import LooseVersion -from gi.repository import GObject as gobject -from textwrap import dedent - -import hamster -from hamster.lib.dbus import ( - DBusMainLoop, - from_dbus_fact_json, - to_dbus_date, - to_dbus_fact, - to_dbus_fact_json, - to_dbus_range, - ) -from hamster.lib.fact import Fact, FactError -from hamster.lib import datetime as dt - - -# bug fixed in dbus-python 1.2.14 (released on 2019-11-25) -assert not ( - sys.version_info >= (3, 8) - and LooseVersion(dbus.__version__) < LooseVersion("1.2.14") - ), """python3.8 changed str(). - That broke hamster (https://github.com/projecthamster/hamster/issues/477). - Please upgrade to dbus-python >= 1.2.14. - """ - - -class Storage(gobject.GObject): - """Hamster client class, communicating to hamster storage daemon via d-bus. - Subscribe to the `tags-changed`, `facts-changed` and `activities-changed` - signals to be notified when an appropriate factoid of interest has been - changed. - - In storage a distinguishment is made between the classificator of - activities and the event in tracking log. - When talking about the event we use term 'fact'. For the classificator - we use term 'activity'. - The relationship is - one activity can be used in several facts. - The rest is hopefully obvious. But if not, please file bug reports! - """ - __gsignals__ = { - "tags-changed": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, ()), - "facts-changed": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, ()), - "activities-changed": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, ()), - "toggle-called": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, ()), - } - - def __init__(self): - gobject.GObject.__init__(self) - - DBusMainLoop(set_as_default=True) - self.bus = dbus.SessionBus() - self._connection = None # will be initiated on demand - - self.bus.add_signal_receiver(self._on_tags_changed, 'TagsChanged', 'org.gnome.Hamster') - self.bus.add_signal_receiver(self._on_facts_changed, 'FactsChanged', 'org.gnome.Hamster') - self.bus.add_signal_receiver(self._on_activities_changed, 'ActivitiesChanged', 'org.gnome.Hamster') - self.bus.add_signal_receiver(self._on_toggle_called, 'ToggleCalled', 'org.gnome.Hamster') - - self.bus.add_signal_receiver(self._on_dbus_connection_change, 'NameOwnerChanged', - 'org.freedesktop.DBus', arg0='org.gnome.Hamster') - @staticmethod - def _to_dict(columns, result_list): - return [dict(zip(columns, row)) for row in result_list] - - @property - def conn(self): - if not self._connection: - self._connection = dbus.Interface(self.bus.get_object('org.gnome.Hamster', - '/org/gnome/Hamster'), - dbus_interface='org.gnome.Hamster') - server_version = self._connection.Version() - client_version = hamster.__version__ - if server_version != client_version: - logger.warning(dedent( - """\ - Server and client version mismatch: - server: {} - client: {} - - This is sometimes used during bisections, - but generally calls for trouble. - - Remember to kill hamster daemons after any version change - (this is safe): - pkill -f hamster-service - pkill -f hamster-windows-service - see also: - https://github.com/projecthamster/hamster#kill-hamster-daemons - """.format(server_version, client_version) - ) - ) - return self._connection - - def _on_dbus_connection_change(self, name, old, new): - self._connection = None - - def _on_tags_changed(self): - self.emit("tags-changed") - - def _on_facts_changed(self): - self.emit("facts-changed") - - def _on_activities_changed(self): - self.emit("activities-changed") - - def _on_toggle_called(self): - self.emit("toggle-called") - - def toggle(self): - """toggle visibility of the main application window if any""" - self.conn.Toggle() - - def get_todays_facts(self): - """returns facts of the current date, respecting hamster midnight - hamster midnight is stored in gconf, and presented in minutes - """ - return [from_dbus_fact_json(fact) for fact in self.conn.GetTodaysFactsJSON()] - - def get_facts(self, start, end=None, search_terms=""): - """Returns facts for the time span matching the optional filter criteria. - In search terms comma (",") translates to boolean OR and space (" ") - to boolean AND. - Filter is applied to tags, categories, activity names and description - """ - range = dt.Range.from_start_end(start, end) - dbus_range = to_dbus_range(range) - return [from_dbus_fact_json(fact) - for fact in self.conn.GetFactsJSON(dbus_range, search_terms)] - - def get_activities(self, search = ""): - """returns list of activities name matching search criteria. - results are sorted by most recent usage. - search is case insensitive - """ - return self._to_dict(('name', 'category'), self.conn.GetActivities(search)) - - def get_categories(self): - """returns list of categories""" - return self._to_dict(('id', 'name'), self.conn.GetCategories()) - - def get_tags(self, only_autocomplete = False): - """returns list of all tags. by default only those that have been set for autocomplete""" - return self._to_dict(('id', 'name', 'autocomplete'), self.conn.GetTags(only_autocomplete)) - - - def get_tag_ids(self, tags): - """find tag IDs by name. tags should be a list of labels - if a requested tag had been removed from the autocomplete list, it - will be ressurrected. if tag with such label does not exist, it will - be created. - on database changes the `tags-changed` signal is emitted. - """ - return self._to_dict(('id', 'name', 'autocomplete'), self.conn.GetTagIds(tags)) - - def update_autocomplete_tags(self, tags): - """update list of tags that should autocomplete. this list replaces - anything that is currently set""" - self.conn.SetTagsAutocomplete(tags) - - def get_fact(self, id): - """returns fact by it's ID""" - return from_dbus_fact_json(self.conn.GetFactJSON(id)) - - def check_fact(self, fact, default_day=None): - """Check Fact validity for inclusion in the storage. - - default_day (date): Default hamster day, - used to simplify some hint messages - (remove unnecessary dates). - None is safe (always show dates). - """ - if not fact.start_time: - # Do not even try to pass fact through D-Bus as - # conversions would fail in this case. - raise FactError("Missing start time") - dbus_fact = to_dbus_fact_json(fact) - dbus_day = to_dbus_date(default_day) - success, message = self.conn.CheckFact(dbus_fact, dbus_day) - if not success: - raise FactError(message) - return success, message - - def add_fact(self, fact, temporary_activity = False): - """Add fact (Fact).""" - assert fact.activity, "missing activity" - - if not fact.start_time: - logger.info("Adding fact without any start_time is deprecated") - fact.start_time = dt.datetime.now() - - dbus_fact = to_dbus_fact_json(fact) - new_id = self.conn.AddFactJSON(dbus_fact) - - return new_id - - def stop_tracking(self, end_time = None): - """Stop tracking current activity. end_time can be passed in if the - activity should have other end time than the current moment""" - end_time = timegm((end_time or dt.datetime.now()).timetuple()) - return self.conn.StopTracking(end_time) - - def remove_fact(self, fact_id): - "delete fact from database" - self.conn.RemoveFact(fact_id) - - def update_fact(self, fact_id, fact, temporary_activity = False): - """Update fact values. See add_fact for rules. - Update is performed via remove/insert, so the - fact_id after update should not be used anymore. Instead use the ID - from the fact dict that is returned by this function""" - - dbus_fact = to_dbus_fact_json(fact) - new_id = self.conn.UpdateFactJSON(fact_id, dbus_fact) - - return new_id - - - def get_category_activities(self, category_id = None): - """Return activities for category. If category is not specified, will - return activities that have no category""" - category_id = category_id or -1 - return self._to_dict(('id', 'name', 'category_id', 'category'), self.conn.GetCategoryActivities(category_id)) - - def get_category_id(self, category_name): - """returns category id by name""" - return self.conn.GetCategoryId(category_name) - - def get_activity_by_name(self, activity, category_id = None, resurrect = True): - """returns activity dict by name and optionally filtering by category. - if activity is found but is marked as deleted, it will be resurrected - unless told otherwise in the resurrect param - """ - category_id = category_id or 0 - return self.conn.GetActivityByName(activity, category_id, resurrect) - - # category and activity manipulations (normally just via preferences) - def remove_activity(self, id): - self.conn.RemoveActivity(id) - - def remove_category(self, id): - self.conn.RemoveCategory(id) - - def change_category(self, id, category_id): - return self.conn.ChangeCategory(id, category_id) - - def update_activity(self, id, name, category_id): - return self.conn.UpdateActivity(id, name, category_id) - - def add_activity(self, name, category_id = -1): - return self.conn.AddActivity(name, category_id) - - def update_category(self, id, name): - return self.conn.UpdateCategory(id, name) - - def add_category(self, name): - return self.conn.AddCategory(name) diff --git a/src/hamster/client.py b/src/hamster/client.py new file mode 120000 index 000000000..8f191fb5e --- /dev/null +++ b/src/hamster/client.py @@ -0,0 +1 @@ +dbus/client.py \ No newline at end of file diff --git a/src/hamster/dbus/client.py b/src/hamster/dbus/client.py new file mode 100644 index 000000000..f6690a601 --- /dev/null +++ b/src/hamster/dbus/client.py @@ -0,0 +1,285 @@ +# - coding: utf-8 - + +# Copyright (C) 2007 Patryk Zawadzki +# Copyright (C) 2007-2009 Toms Baugis + +# This file is part of Project Hamster. + +# Project Hamster is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# Project Hamster is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with Project Hamster. If not, see . + + +import dbus +import logging +logger = logging.getLogger(__name__) # noqa: E402 +import sys + +from calendar import timegm +from distutils.version import LooseVersion +from gi.repository import GObject as gobject +from textwrap import dedent + +import hamster +from hamster.dbus.utilities import ( + DBusMainLoop, + from_dbus_fact_json, + to_dbus_date, + to_dbus_fact, + to_dbus_fact_json, + to_dbus_range, + ) +from hamster.lib.fact import Fact, FactError +from hamster.lib import datetime as dt +from hamster.storage import storage + + +# bug fixed in dbus-python 1.2.14 (released on 2019-11-25) +assert not ( + sys.version_info >= (3, 8) + and LooseVersion(dbus.__version__) < LooseVersion("1.2.14") + ), """python3.8 changed str(). + That broke hamster (https://github.com/projecthamster/hamster/issues/477). + Please upgrade to dbus-python >= 1.2.14. + """ + + +class Storage(storage.Storage): + """Hamster client class, communicating to hamster storage daemon via d-bus. + Subscribe to the `tags-changed`, `facts-changed` and `activities-changed` + signals to be notified when an appropriate factoid of interest has been + changed. + + In storage a distinguishment is made between the classificator of + activities and the event in tracking log. + When talking about the event we use term 'fact'. For the classificator + we use term 'activity'. + The relationship is - one activity can be used in several facts. + The rest is hopefully obvious. But if not, please file bug reports! + """ + + # add signal to the "tags-changed", "facts-changed", "activities-changed" + # inherited from storage.Storage + __gsignals__ = { + "toggle-called": (gobject.SignalFlags.RUN_LAST, gobject.TYPE_NONE, ()), + } + + def __init__(self): + storage.Storage.__init__(self) + + DBusMainLoop(set_as_default=True) + self.bus = dbus.SessionBus() + self._connection = None # will be initiated on demand + + self.bus.add_signal_receiver(self._on_tags_changed, 'TagsChanged', 'org.gnome.Hamster') + self.bus.add_signal_receiver(self._on_facts_changed, 'FactsChanged', 'org.gnome.Hamster') + self.bus.add_signal_receiver(self._on_activities_changed, 'ActivitiesChanged', 'org.gnome.Hamster') + self.bus.add_signal_receiver(self._on_toggle_called, 'ToggleCalled', 'org.gnome.Hamster') + + self.bus.add_signal_receiver(self._on_dbus_connection_change, 'NameOwnerChanged', + 'org.freedesktop.DBus', arg0='org.gnome.Hamster') + @staticmethod + def _to_dict(columns, result_list): + return [dict(zip(columns, row)) for row in result_list] + + @property + def conn(self): + if not self._connection: + self._connection = dbus.Interface(self.bus.get_object('org.gnome.Hamster', + '/org/gnome/Hamster'), + dbus_interface='org.gnome.Hamster') + server_version = self._connection.Version() + client_version = hamster.__version__ + if server_version != client_version: + logger.warning(dedent( + """\ + Server and client version mismatch: + server: {} + client: {} + + This is sometimes used during bisections, + but generally calls for trouble. + + Remember to kill hamster daemons after any version change + (this is safe): + pkill -f hamster-service + pkill -f hamster-windows-service + see also: + https://github.com/projecthamster/hamster#kill-hamster-daemons + """.format(server_version, client_version) + ) + ) + return self._connection + + def _on_dbus_connection_change(self, name, old, new): + self._connection = None + + def _on_tags_changed(self): + self.emit("tags-changed") + + def _on_facts_changed(self): + self.emit("facts-changed") + + def _on_activities_changed(self): + self.emit("activities-changed") + + def _on_toggle_called(self): + self.emit("toggle-called") + + def toggle(self): + """toggle visibility of the main application window if any""" + self.conn.Toggle() + + def get_todays_facts(self): + """returns facts of the current date, respecting hamster midnight + hamster midnight is stored in gconf, and presented in minutes + """ + return [from_dbus_fact_json(fact) for fact in self.conn.GetTodaysFactsJSON()] + + def get_facts(self, start, end=None, search_terms=""): + """Returns facts for the time span matching the optional filter criteria. + In search terms comma (",") translates to boolean OR and space (" ") + to boolean AND. + Filter is applied to tags, categories, activity names and description + """ + range = dt.Range.from_start_end(start, end) + dbus_range = to_dbus_range(range) + return [from_dbus_fact_json(fact) + for fact in self.conn.GetFactsJSON(dbus_range, search_terms)] + + def get_activities(self, search = ""): + """returns list of activities name matching search criteria. + results are sorted by most recent usage. + search is case insensitive + """ + return self._to_dict(('name', 'category'), self.conn.GetActivities(search)) + + def get_categories(self): + """returns list of categories""" + return self._to_dict(('id', 'name'), self.conn.GetCategories()) + + def get_tags(self, only_autocomplete = False): + """returns list of all tags. by default only those that have been set for autocomplete""" + return self._to_dict(('id', 'name', 'autocomplete'), self.conn.GetTags(only_autocomplete)) + + + def get_tag_ids(self, tags): + """find tag IDs by name. tags should be a list of labels + if a requested tag had been removed from the autocomplete list, it + will be ressurrected. if tag with such label does not exist, it will + be created. + on database changes the `tags-changed` signal is emitted. + """ + return self._to_dict(('id', 'name', 'autocomplete'), self.conn.GetTagIds(tags)) + + def update_autocomplete_tags(self, tags): + """update list of tags that should autocomplete. this list replaces + anything that is currently set""" + self.conn.SetTagsAutocomplete(tags) + + def get_fact(self, id): + """returns fact by it's ID""" + return from_dbus_fact_json(self.conn.GetFactJSON(id)) + + def check_fact(self, fact, default_day=None): + """Check Fact validity for inclusion in the storage. + + default_day (date): Default hamster day, + used to simplify some hint messages + (remove unnecessary dates). + None is safe (always show dates). + """ + if not fact.start_time: + # Do not even try to pass fact through D-Bus as + # conversions would fail in this case. + raise FactError("Missing start time") + dbus_fact = to_dbus_fact_json(fact) + dbus_day = to_dbus_date(default_day) + success, message = self.conn.CheckFact(dbus_fact, dbus_day) + if not success: + raise FactError(message) + return success, message + + def add_fact(self, fact, temporary_activity = False): + """Add fact (Fact).""" + assert fact.activity, "missing activity" + + if not fact.start_time: + logger.info("Adding fact without any start_time is deprecated") + fact.start_time = dt.datetime.now() + + dbus_fact = to_dbus_fact_json(fact) + new_id = self.conn.AddFactJSON(dbus_fact) + + return new_id + + def stop_tracking(self, end_time = None): + """Stop tracking current activity. end_time can be passed in if the + activity should have other end time than the current moment""" + end_time = timegm((end_time or dt.datetime.now()).timetuple()) + return self.conn.StopTracking(end_time) + + def remove_fact(self, fact_id): + "delete fact from database" + self.conn.RemoveFact(fact_id) + + def update_fact(self, fact_id, fact, temporary_activity = False): + """Update fact values. See add_fact for rules. + Update is performed via remove/insert, so the + fact_id after update should not be used anymore. Instead use the ID + from the fact dict that is returned by this function""" + + dbus_fact = to_dbus_fact_json(fact) + new_id = self.conn.UpdateFactJSON(fact_id, dbus_fact) + + return new_id + + + def get_category_activities(self, category_id = None): + """Return activities for category. If category is not specified, will + return activities that have no category""" + category_id = category_id or -1 + return self._to_dict(('id', 'name', 'category_id', 'category'), self.conn.GetCategoryActivities(category_id)) + + def get_category_id(self, category_name): + """returns category id by name""" + return self.conn.GetCategoryId(category_name) + + def get_activity_by_name(self, activity, category_id = None, resurrect = True): + """returns activity dict by name and optionally filtering by category. + if activity is found but is marked as deleted, it will be resurrected + unless told otherwise in the resurrect param + """ + category_id = category_id or 0 + return self.conn.GetActivityByName(activity, category_id, resurrect) + + # category and activity manipulations (normally just via preferences) + def remove_activity(self, id): + self.conn.RemoveActivity(id) + + def remove_category(self, id): + self.conn.RemoveCategory(id) + + def change_category(self, id, category_id): + return self.conn.ChangeCategory(id, category_id) + + def update_activity(self, id, name, category_id): + return self.conn.UpdateActivity(id, name, category_id) + + def add_activity(self, name, category_id = -1): + return self.conn.AddActivity(name, category_id) + + def update_category(self, id, name): + return self.conn.UpdateCategory(id, name) + + def add_category(self, name): + return self.conn.AddCategory(name) diff --git a/src/hamster/dbus/utilities.py b/src/hamster/dbus/utilities.py new file mode 100644 index 000000000..6ec22e767 --- /dev/null +++ b/src/hamster/dbus/utilities.py @@ -0,0 +1,130 @@ +import dbus + +from dbus.mainloop.glib import DBusGMainLoop as DBusMainLoop +from json import dumps, loads +from calendar import timegm +from hamster.lib import datetime as dt +from hamster.lib.fact import Fact + + +"""D-Bus communication utilities. + +Note: the old hamster.lib.dbus linking to hamster.dbus.utilities + will be removed eventually. + It stays there only for backward compatibility + (since python3 does not allow module aliases with "from" imports). + hamster.dbus.utilities is the new one in the post v3.0 master. +""" + +# file layout: functions sorted in alphabetical order, +# not taking into account the "to_" and "from_" prefixes. +# So back and forth conversions are close to one another. + + +# dates + +def from_dbus_date(dbus_date): + """Convert D-Bus timestamp (seconds since epoch) to date.""" + return dt.date.fromtimestamp(dbus_date) if dbus_date else None + + +def to_dbus_date(date): + """Convert date to D-Bus timestamp (seconds since epoch).""" + return timegm(date.timetuple()) if date else 0 + + +# facts + +def from_dbus_fact_json(dbus_fact): + """Convert D-Bus JSON to Fact.""" + d = loads(dbus_fact) + range_d = d['range'] + # should use pdt.datetime.fromisoformat, + # but that appears only in python3.7, nevermind + start_s = range_d['start'] + end_s = range_d['end'] + range = dt.Range(start=dt.datetime.parse(start_s) if start_s else None, + end=dt.datetime.parse(end_s) if end_s else None) + d['range'] = range + return Fact(**d) + + +def to_dbus_fact_json(fact): + """Convert Fact to D-Bus JSON (str).""" + d = {} + keys = ('activity', 'category', 'description', 'tags', 'id', 'activity_id') + for key in keys: + d[key] = getattr(fact, key) + # isoformat(timespec="minutes") appears only in python3.6, nevermind + # and fromisoformat is not available anyway, so let's talk hamster + start = str(fact.range.start) if fact.range.start else None + end = str(fact.range.end) if fact.range.end else None + d['range'] = {'start': start, 'end': end} + return dumps(d) + + +# Range + +def from_dbus_range(dbus_range): + """Convert from D-Bus string to dt.Range.""" + range, __ = dt.Range.parse(dbus_range, position="exact") + return range + + +def to_dbus_range(range): + """Convert dt.Range to D-Bus string.""" + + # no default_day, to always output in the same format + return range.format(default_day=None) + + +# Legacy functions: + +""" +old dbus_fact signature (types matching the to_dbus_fact output) + i id + i start_time + i end_time + s description + s activity name + i activity id + s category name + as List of fact tags + i date + i delta +""" +fact_signature = '(iiissisasii)' + + +def from_dbus_fact(dbus_fact): + """Unpack the struct into a proper dict. + + Legacy: to besuperceded by from_dbus_fact_json at some point. + """ + return Fact(activity=dbus_fact[4], + start_time=dt.datetime.utcfromtimestamp(dbus_fact[1]), + end_time=dt.datetime.utcfromtimestamp(dbus_fact[2]) if dbus_fact[2] else None, + description=dbus_fact[3], + activity_id=dbus_fact[5], + category=dbus_fact[6], + tags=dbus_fact[7], + id=dbus_fact[0] + ) + + +def to_dbus_fact(fact): + """Perform Fact conversion to D-Bus. + + Return the corresponding dbus structure, with supported data types. + Legacy: to besuperceded by to_dbus_fact_json at some point. + """ + return (fact.id or 0, + timegm(fact.start_time.timetuple()), + timegm(fact.end_time.timetuple()) if fact.end_time else 0, + fact.description or '', + fact.activity or '', + fact.activity_id or 0, + fact.category or '', + dbus.Array(fact.tags, signature = 's'), + to_dbus_date(fact.date), + fact.delta.days * 24 * 60 * 60 + fact.delta.seconds) diff --git a/src/hamster/edit_activity.py b/src/hamster/edit_activity.py index 59c2d6f43..a24e63e3b 100644 --- a/src/hamster/edit_activity.py +++ b/src/hamster/edit_activity.py @@ -28,21 +28,24 @@ """ from hamster import widgets from hamster.lib import datetime as dt -from hamster.lib.configuration import Controller, runtime, load_ui_file +from hamster.lib.configuration import Controller, load_ui_file from hamster.lib.fact import Fact, FactError from hamster.lib.stuff import escape_pango - class CustomFactController(Controller): """Controller for a Fact edition window. Args: action (str): "add", "clone", "edit" + storage (storage.Storage): + A concrete storage instance, + usually a dbus.client.Storage, + sometimes a storage.db.Storage directly. fact_id (int): used for "clone" and "edit" """ - def __init__(self, action, fact_id=None): + def __init__(self, action, storage, fact_id=None): Controller.__init__(self) self._date = None # for the date property @@ -53,13 +56,16 @@ def __init__(self, action, fact_id=None): self.action = action + self.storage = storage self.fact_id = fact_id - self.category_entry = widgets.CategoryEntry(widget=self.get_widget('category')) - self.activity_entry = widgets.ActivityEntry(widget=self.get_widget('activity'), + self.category_entry = widgets.CategoryEntry(self.storage, + widget=self.get_widget('category')) + self.activity_entry = widgets.ActivityEntry(self.storage, + widget=self.get_widget('activity'), category_widget=self.category_entry) - self.cmdline = widgets.CmdLineEntry(parent=self.get_widget("cmdline box")) + self.cmdline = widgets.CmdLineEntry(self.storage, parent=self.get_widget("cmdline box")) self.cmdline.connect("focus_in_event", self.on_cmdline_focus_in_event) self.cmdline.connect("focus_out_event", self.on_cmdline_focus_out_event) @@ -79,7 +85,7 @@ def __init__(self, action, fact_id=None): self.start_time = widgets.TimeInput(parent=self.get_widget("start time box")) - self.tags_entry = widgets.TagsEntry() + self.tags_entry = widgets.TagsEntry(self.storage) self.get_widget("tags box").add(self.tags_entry) self.save_button = self.get_widget("save_button") @@ -91,9 +97,9 @@ def __init__(self, action, fact_id=None): self.window.set_title(title) self.get_widget("delete_button").set_sensitive(action == "edit") if action == "edit": - self.fact = runtime.storage.get_fact(fact_id) + self.fact = self.storage.get_fact(fact_id) elif action == "clone": - base_fact = runtime.storage.get_fact(fact_id) + base_fact = self.storage.get_fact(fact_id) self.fact = base_fact.copy(start_time=dt.datetime.now(), end_time=None) else: @@ -152,7 +158,7 @@ def on_next_day_clicked(self, button): self.increment_date(+1) def draw_preview(self, start_time, end_time=None): - day_facts = runtime.storage.get_facts(self.date) + day_facts = self.storage.get_facts(self.date) self.dayline.plot(self.date, day_facts, start_time, end_time) def get_widget(self, name): @@ -347,7 +353,7 @@ def validate_fields(self): fact.end_time or default_dt) try: - runtime.storage.check_fact(fact, default_day=self.date) + self.storage.check_fact(fact, default_day=self.date) except FactError as error: self.update_status(status="wrong", markup=str(error)) return None @@ -362,7 +368,7 @@ def validate_fields(self): return fact def on_delete_clicked(self, button): - runtime.storage.remove_fact(self.fact_id) + self.storage.remove_fact(self.fact_id) self.close_window() def on_cancel_clicked(self, button): @@ -373,9 +379,9 @@ def on_close(self, widget, event): def on_save_button_clicked(self, button): if self.action == "edit": - runtime.storage.update_fact(self.fact_id, self.fact) + self.storage.update_fact(self.fact_id, self.fact) else: - runtime.storage.add_fact(self.fact) + self.storage.add_fact(self.fact) self.close_window() def on_window_key_pressed(self, tree, event_key): diff --git a/src/hamster/lib/configuration.py b/src/hamster/lib/configuration.py index 24b42c668..b709fb5b0 100644 --- a/src/hamster/lib/configuration.py +++ b/src/hamster/lib/configuration.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) # noqa: E402 import os -from hamster.client import Storage + from xdg.BaseDirectory import xdg_data_home from gi.repository import Gdk as gdk @@ -82,7 +82,7 @@ def present(self): def show(self): """Show window. - It might be obscurd by others though. + It might be obscured by others though. See also: presents """ self.window.show() @@ -104,29 +104,6 @@ def __new__(cls, *args, **kwargs): cls.__instance = object.__new__(cls, *args, **kwargs) return cls.__instance -class RuntimeStore(Singleton): - """XXX - kill""" - data_dir = "" - home_data_dir = "" - storage = None - - def __init__(self): - self.version = hamster.__version__ - if hamster.installed: - from hamster import defs # only available when running installed - self.data_dir = os.path.join(defs.DATA_DIR, "hamster") - else: - # running from sources - module_dir = os.path.dirname(os.path.realpath(__file__)) - self.data_dir = os.path.join(module_dir, '..', '..', '..', 'data') - - self.data_dir = os.path.realpath(self.data_dir) - self.storage = Storage() - self.home_data_dir = os.path.realpath(os.path.join(xdg_data_home, "hamster")) - - -runtime = RuntimeStore() - class GSettingsStore(gobject.GObject, Singleton): """ @@ -142,6 +119,18 @@ def __init__(self): gobject.GObject.__init__(self) self._settings = gio.Settings(schema_id='org.gnome.Hamster') + # directory holding general data (for instance .ui files) + if hamster.installed: + from hamster import defs # only available when running installed + self.data_dir = os.path.join(defs.DATA_DIR, "hamster") + else: + # running from sources + module_dir = os.path.dirname(os.path.realpath(__file__)) + self.data_dir = os.path.join(module_dir, '..', '..', '..', 'data') + + # directory holding user data + self.home_data_dir = os.path.realpath(os.path.join(xdg_data_home, "hamster")) + def _key_changed(self, client, key, data=None): """ Callback when a GSettings key changes @@ -183,3 +172,37 @@ def day_start(self): conf = GSettingsStore() + + +class RuntimeStore(Singleton): + """Legacy data and storage centralization. + + Deprecated. Use directly + from hamster.dbus.client import Storage + self.storage = Storage() + and + from hamster.lib.configuration import conf + conf.data_dir + conf.home_data_dir + """ + + def __init__(self): + self.version = hamster.__version__ + self._storage = None + self.data_dir = conf.data_dir + self.home_data_dir = conf.home_data_dir + + @property + def storage(self): + """D-Bus storage interface. + + Deprecated, see the `RuntimeStore` docstring. + """ + if not self._storage: + from hamster.dbus.client import Storage + self._storage = Storage() + return self._storage + + +#: Deprecated (see RuntimeStore) +runtime = RuntimeStore() diff --git a/src/hamster/lib/dbus.py b/src/hamster/lib/dbus.py deleted file mode 100644 index f282e4fdc..000000000 --- a/src/hamster/lib/dbus.py +++ /dev/null @@ -1,123 +0,0 @@ -import dbus - -from dbus.mainloop.glib import DBusGMainLoop as DBusMainLoop -from json import dumps, loads -from calendar import timegm -from hamster.lib import datetime as dt -from hamster.lib.fact import Fact - - -"""D-Bus communication utilities.""" - -# file layout: functions sorted in alphabetical order, -# not taking into account the "to_" and "from_" prefixes. -# So back and forth conversions are close to one another. - - -# dates - -def from_dbus_date(dbus_date): - """Convert D-Bus timestamp (seconds since epoch) to date.""" - return dt.date.fromtimestamp(dbus_date) if dbus_date else None - - -def to_dbus_date(date): - """Convert date to D-Bus timestamp (seconds since epoch).""" - return timegm(date.timetuple()) if date else 0 - - -# facts - -def from_dbus_fact_json(dbus_fact): - """Convert D-Bus JSON to Fact.""" - d = loads(dbus_fact) - range_d = d['range'] - # should use pdt.datetime.fromisoformat, - # but that appears only in python3.7, nevermind - start_s = range_d['start'] - end_s = range_d['end'] - range = dt.Range(start=dt.datetime.parse(start_s) if start_s else None, - end=dt.datetime.parse(end_s) if end_s else None) - d['range'] = range - return Fact(**d) - - -def to_dbus_fact_json(fact): - """Convert Fact to D-Bus JSON (str).""" - d = {} - keys = ('activity', 'category', 'description', 'tags', 'id', 'activity_id') - for key in keys: - d[key] = getattr(fact, key) - # isoformat(timespec="minutes") appears only in python3.6, nevermind - # and fromisoformat is not available anyway, so let's talk hamster - start = str(fact.range.start) if fact.range.start else None - end = str(fact.range.end) if fact.range.end else None - d['range'] = {'start': start, 'end': end} - return dumps(d) - - -# Range - -def from_dbus_range(dbus_range): - """Convert from D-Bus string to dt.Range.""" - range, __ = dt.Range.parse(dbus_range, position="exact") - return range - - -def to_dbus_range(range): - """Convert dt.Range to D-Bus string.""" - - # no default_day, to always output in the same format - return range.format(default_day=None) - - -# Legacy functions: - -""" -old dbus_fact signature (types matching the to_dbus_fact output) - i id - i start_time - i end_time - s description - s activity name - i activity id - s category name - as List of fact tags - i date - i delta -""" -fact_signature = '(iiissisasii)' - - -def from_dbus_fact(dbus_fact): - """Unpack the struct into a proper dict. - - Legacy: to besuperceded by from_dbus_fact_json at some point. - """ - return Fact(activity=dbus_fact[4], - start_time=dt.datetime.utcfromtimestamp(dbus_fact[1]), - end_time=dt.datetime.utcfromtimestamp(dbus_fact[2]) if dbus_fact[2] else None, - description=dbus_fact[3], - activity_id=dbus_fact[5], - category=dbus_fact[6], - tags=dbus_fact[7], - id=dbus_fact[0] - ) - - -def to_dbus_fact(fact): - """Perform Fact conversion to D-Bus. - - Return the corresponding dbus structure, with supported data types. - Legacy: to besuperceded by to_dbus_fact_json at some point. - """ - return (fact.id or 0, - timegm(fact.start_time.timetuple()), - timegm(fact.end_time.timetuple()) if fact.end_time else 0, - fact.description or '', - fact.activity or '', - fact.activity_id or 0, - fact.category or '', - dbus.Array(fact.tags, signature = 's'), - to_dbus_date(fact.date), - fact.delta.days * 24 * 60 * 60 + fact.delta.seconds) diff --git a/src/hamster/lib/dbus.py b/src/hamster/lib/dbus.py new file mode 120000 index 000000000..ff825425d --- /dev/null +++ b/src/hamster/lib/dbus.py @@ -0,0 +1 @@ +../dbus/utilities.py \ No newline at end of file diff --git a/src/hamster/overview.py b/src/hamster/overview.py index b83fce159..e0d514c81 100644 --- a/src/hamster/overview.py +++ b/src/hamster/overview.py @@ -33,7 +33,6 @@ from gi.repository import Pango as pango import cairo -import hamster.client from hamster.lib import datetime as dt from hamster.lib import graphics from hamster.lib import layout @@ -410,8 +409,16 @@ def update_colors(self): class Overview(Controller): - def __init__(self): - Controller.__init__(self) + """Controller for an overview window. + + Args: + storage (storage.Storage): + A concrete storage instance, + usually a dbus.client.Storage, + sometimes a storage.db.Storage directly. + """ + def __init__(self, storage, **kwds): + Controller.__init__(self, **kwds) self.prefs_dialog = None # preferences dialog controller @@ -419,7 +426,7 @@ def __init__(self): self.window.set_default_icon_name("org.gnome.Hamster.GUI") self.window.set_default_size(700, 500) - self.storage = hamster.client.Storage() + self.storage = storage self.storage.connect("facts-changed", self.on_facts_changed) self.storage.connect("activities-changed", self.on_facts_changed) @@ -550,7 +557,7 @@ def on_search_icon_press(self, entry, position, event): if position == gtk.EntryIconPosition.SECONDARY: self.filter_entry.set_text("") - def on_facts_changed(self, event): + def on_facts_changed(self, storage): self.find_facts() def on_add_activity_clicked(self, button): diff --git a/src/hamster/preferences.py b/src/hamster/preferences.py index c41b1d35c..827a50a97 100644 --- a/src/hamster/preferences.py +++ b/src/hamster/preferences.py @@ -24,7 +24,7 @@ from hamster import widgets from hamster.lib import datetime as dt from hamster.lib import stuff -from hamster.lib.configuration import Controller, runtime, conf +from hamster.lib.configuration import Controller, conf def get_prev(selection, model): @@ -39,12 +39,13 @@ def get_prev(selection, model): class CategoryStore(gtk.ListStore): - def __init__(self): + def __init__(self, storage): + self.storage = storage #id, name, color_code, order gtk.ListStore.__init__(self, int, str) def load(self): - category_list = runtime.storage.get_categories() + category_list = self.storage.get_categories() for category in category_list: self.append([category['id'], category['name']]) @@ -53,7 +54,8 @@ def load(self): class ActivityStore(gtk.ListStore): - def __init__(self): + def __init__(self, storage): + self.storage = storage #id, name, category_id, order gtk.ListStore.__init__(self, int, str, int) @@ -63,7 +65,7 @@ def load(self, category_id): if category_id is None: return - activity_list = runtime.storage.get_category_activities(category_id) + activity_list = self.storage.get_category_activities(category_id) for activity in activity_list: self.append([activity['id'], @@ -72,18 +74,31 @@ def load(self, category_id): class PreferencesEditor(Controller): + """Preferences editor controller. + + Args: + storage (storage.Storage): + A concrete storage instance, + usually a dbus.client.Storage, + sometimes a storage.db.Storage directly. + Used to manage activities, categories and tags. + Other preferences are managed through GSettings. + """ + TARGETS = [ ('MY_TREE_MODEL_ROW', gtk.TargetFlags.SAME_WIDGET, 0), ('MY_TREE_MODEL_ROW', gtk.TargetFlags.SAME_APP, 0), ] - def __init__(self): + def __init__(self, storage): Controller.__init__(self, ui_file="preferences.ui") + self.storage = storage + # create and fill activity tree self.activity_tree = self.get_widget('activity_list') self.get_widget("activities_label").set_mnemonic_widget(self.activity_tree) - self.activity_store = ActivityStore() + self.activity_store = ActivityStore(self.storage) self.external_listeners = [] @@ -109,7 +124,7 @@ def __init__(self): # create and fill category tree self.category_tree = self.get_widget('category_list') self.get_widget("categories_label").set_mnemonic_widget(self.category_tree) - self.category_store = CategoryStore() + self.category_store = CategoryStore(self.storage) self.categoryColumn = gtk.TreeViewColumn(_("Category")) self.categoryColumn.set_expand(True) @@ -171,7 +186,7 @@ def show(self): def load_config(self, *args): self.day_start.time = conf.day_start - self.tags = [tag["name"] for tag in runtime.storage.get_tags(only_autocomplete=True)] + self.tags = [tag["name"] for tag in self.storage.get_tags(only_autocomplete=True)] self.get_widget("autocomplete_tags").set_text(", ".join(self.tags)) def on_autocomplete_tags_view_focus_out_event(self, view, event): @@ -182,7 +197,7 @@ def on_autocomplete_tags_view_focus_out_event(self, view, event): self.tags = updated_tags - runtime.storage.update_autocomplete_tags(updated_tags) + self.storage.update_autocomplete_tags(updated_tags) def drag_data_get_data(self, treeview, context, selection, target_id, etime): @@ -234,7 +249,7 @@ def on_category_drop(self, treeview, context, x, y, selection, if drop_info: path, position = drop_info iter = model.get_iter(path) - changed = runtime.storage.change_category(int(data), model[iter][0]) + changed = self.storage.change_category(int(data), model[iter][0]) context.finish(changed, True, etime) else: @@ -249,7 +264,7 @@ def category_edited_cb(self, cell, path, new_text, model): return False #ignoring unsorted category #look for dupes - categories = runtime.storage.get_categories() + categories = self.storage.get_categories() for category in categories: if category['name'].lower() == new_text.lower(): if id == -2: # that was a new category @@ -258,10 +273,10 @@ def category_edited_cb(self, cell, path, new_text, model): return False if id == -2: #new category - id = runtime.storage.add_category(new_text) + id = self.storage.add_category(new_text) model[path][0] = id else: - runtime.storage.update_category(id, new_text) + self.storage.update_category(id, new_text) model[path][1] = new_text @@ -269,7 +284,7 @@ def activity_name_edited_cb(self, cell, path, new_text, model): id = model[path][0] category_id = model[path][2] - activities = runtime.storage.get_category_activities(category_id) + activities = self.storage.get_category_activities(category_id) prev = None for activity in activities: if id == activity['id']: @@ -283,10 +298,10 @@ def activity_name_edited_cb(self, cell, path, new_text, model): return False if id == -1: #new activity -> add - model[path][0] = runtime.storage.add_activity(new_text, category_id) + model[path][0] = self.storage.add_activity(new_text, category_id) else: #existing activity -> update new = new_text - runtime.storage.update_activity(id, new, category_id) + self.storage.update_activity(id, new, category_id) model[path][1] = new_text return True @@ -411,7 +426,7 @@ def on_activity_list_key_pressed(self, tree, event_key): def remove_current_activity(self): selection = self.activity_tree.get_selection() (model, iter) = selection.get_selected() - runtime.storage.remove_activity(model[iter][0]) + self.storage.remove_activity(model[iter][0]) self._del_selected_row(self.activity_tree) def on_category_remove_clicked(self, button): @@ -446,7 +461,7 @@ def remove_current_category(self): (model, iter) = selection.get_selected() id = model[iter][0] if id != -1: - runtime.storage.remove_category(id) + self.storage.remove_category(id) self._del_selected_row(self.category_tree) def on_preferences_window_key_press(self, widget, event): @@ -498,7 +513,7 @@ def on_activity_add_clicked(self, button): def on_activity_remove_clicked(self, button): removable_id = self._del_selected_row(self.activity_tree) - runtime.storage.remove_activity(removable_id) + self.storage.remove_activity(removable_id) def on_day_start_changed(self, widget): day_start = self.day_start.time diff --git a/src/hamster/reports.py b/src/hamster/reports.py index 69a4a258d..b1040d1e9 100644 --- a/src/hamster/reports.py +++ b/src/hamster/reports.py @@ -30,7 +30,7 @@ from textwrap import dedent from hamster.lib import datetime as dt -from hamster.lib.configuration import runtime +from hamster.lib.configuration import conf from hamster.lib import stuff from hamster.lib.i18n import C_ try: @@ -198,11 +198,11 @@ def __init__(self, path, start_date, end_date): # read the template, allow override - self.override = os.path.exists(os.path.join(runtime.home_data_dir, "report_template.html")) + self.override = os.path.exists(os.path.join(conf.home_data_dir, "report_template.html")) if self.override: - template = os.path.join(runtime.home_data_dir, "report_template.html") + template = os.path.join(conf.home_data_dir, "report_template.html") else: - template = os.path.join(runtime.data_dir, "report_template.html") + template = os.path.join(conf.data_dir, "report_template.html") self.main_template = "" with open(template, 'r') as f: @@ -304,9 +304,9 @@ def _finish(self, facts): header_duration = _("Duration"), header_description = _("Description"), - data_dir = runtime.data_dir, + data_dir = conf.data_dir, show_template = _("Show template"), - template_instructions = _("You can override it by storing your version in %(home_folder)s") % {'home_folder': runtime.home_data_dir}, + template_instructions = _("You can override it by storing your version in %(home_folder)s") % {'home_folder': conf.home_data_dir}, start_date = timegm(self.start_date.timetuple()), end_date = timegm(self.end_date.timetuple()), diff --git a/src/hamster/storage/db.py b/src/hamster/storage/db.py index 468a95f9e..a057d40f0 100644 --- a/src/hamster/storage/db.py +++ b/src/hamster/storage/db.py @@ -93,7 +93,9 @@ def on_db_file_change(monitor, gio_file, event_uri, event): if event == gio.FileMonitorEvent.CHANGES_DONE_HINT: logger.warning("DB file has been modified externally. Calling all stations") - self.dispatch_overwrite() + self.emit("tags-changed") + self.emit("facts-changed") + self.emit("activities-changed") self.__database_file = gio.File.new_for_path(self.db_path) self.__db_monitor = self.__database_file.monitor_file(gio.FileMonitorFlags.WATCH_MOUNTS, None) diff --git a/src/hamster/storage/storage.py b/src/hamster/storage/storage.py index c16796241..01023f4fa 100644 --- a/src/hamster/storage/storage.py +++ b/src/hamster/storage/storage.py @@ -21,32 +21,51 @@ import logging logger = logging.getLogger(__name__) # noqa: E402 -from hamster.lib import datetime as dt - from textwrap import dedent +from gi.repository import GObject as gobject + +from hamster.lib import datetime as dt from hamster.lib.fact import Fact, FactError -class Storage(object): +class Storage(gobject.GObject): """Abstract storage. Concrete instances should implement the required private methods, such as __get_facts. """ + def __init__(self): + gobject.GObject.__init__(self) + def run_fixtures(self): pass - # signals that are called upon changes - def tags_changed(self): pass - def facts_changed(self): pass - def activities_changed(self): pass + # do not use tags_changed directly: just call .emit("tags-changed") + # Let's use the dash, to be consistent with the Gnome convention. + @gobject.Signal(name="tags-changed") + def tags_changed(self): + """Handle signal.""" + logger.debug("tags-changed signal") + + @gobject.Signal + def facts_changed(self): + """Handle signal.""" + logger.debug("facts-changed signal") + + @gobject.Signal(name="activities-changed") + def activities_changed(self): + """Handle signal.""" + logger.debug("activities-changed") + + # Deprecated method (2020-02-27): just call .emit(signal) + # What about a master "changed" signal ? Later. def dispatch_overwrite(self): - self.tags_changed() - self.facts_changed() - self.activities_changed() + self.emit("tags-changed") + self.emit("facts-changed") + self.emit("activities-changed") # facts @classmethod @@ -114,7 +133,7 @@ def add_fact(self, fact, start_time=None, end_time=None, temporary=False): self.end_transaction() if result: - self.facts_changed() + self.emit("facts-changed") return result def get_fact(self, fact_id): @@ -135,16 +154,17 @@ def update_fact(self, fact_id, fact, start_time=None, end_time=None, temporary=F logger.warning("failed to update fact {} ({})".format(fact_id, fact)) self.end_transaction() if result: - self.facts_changed() + self.emit("facts-changed") return result - def stop_tracking(self, end_time): - """Stops tracking the current activity""" + def stop_tracking(self, end_time=None): + """Stop tracking the current activity.""" facts = self.__get_todays_facts() if facts and not facts[-1].end_time: + if end_time is None: + end_time = dt.datetime.now() self.__touch_fact(facts[-1], end_time) - self.facts_changed() - + self.emit("facts-changed") def remove_fact(self, fact_id): """Remove fact from storage by it's ID""" @@ -152,7 +172,7 @@ def remove_fact(self, fact_id): fact = self.__get_fact(fact_id) if fact: self.__remove_fact(fact_id) - self.facts_changed() + self.emit("facts-changed") self.end_transaction() @@ -170,7 +190,7 @@ def get_todays_facts(self): # categories def add_category(self, name): res = self.__add_category(name) - self.activities_changed() + self.emit("activities-changed") return res def get_category_id(self, category): @@ -178,11 +198,11 @@ def get_category_id(self, category): def update_category(self, id, name): self.__update_category(id, name) - self.activities_changed() + self.emit("activities-changed") def remove_category(self, id): self.__remove_category(id) - self.activities_changed() + self.emit("activities-changed") def get_categories(self): @@ -192,16 +212,16 @@ def get_categories(self): # activities def add_activity(self, name, category_id = -1): new_id = self.__add_activity(name, category_id) - self.activities_changed() + self.emit("activities-changed") return new_id def update_activity(self, id, name, category_id): self.__update_activity(id, name, category_id) - self.activities_changed() + self.emit("activities-changed") def remove_activity(self, id): result = self.__remove_activity(id) - self.activities_changed() + self.emit("activities-changed") return result def get_category_activities(self, category_id = -1): @@ -213,7 +233,7 @@ def get_activities(self, search = ""): def change_category(self, id, category_id): changed = self.__change_category(id, category_id) if changed: - self.activities_changed() + self.emit("activities-changed") return changed def get_activity_by_name(self, activity, category_id, resurrect = True): @@ -230,10 +250,10 @@ def get_tags(self, only_autocomplete): def get_tag_ids(self, tags): tags, new_added = self.__get_tag_ids(tags) if new_added: - self.tags_changed() + self.emit("tags-changed") return tags def update_autocomplete_tags(self, tags): changes = self.__update_autocomplete_tags(tags) if changes: - self.tags_changed() + self.emit("tags-changed") diff --git a/src/hamster/widgets/activityentry.py b/src/hamster/widgets/activityentry.py index 97a3ac02e..2ef43b07b 100644 --- a/src/hamster/widgets/activityentry.py +++ b/src/hamster/widgets/activityentry.py @@ -32,11 +32,9 @@ from collections import defaultdict from copy import deepcopy -from hamster import client from hamster.lib import datetime as dt from hamster.lib import stuff from hamster.lib import graphics -from hamster.lib.configuration import runtime from hamster.lib.fact import Fact @@ -203,7 +201,17 @@ def on_enter_frame(self, scene, context): class CmdLineEntry(gtk.Entry): - def __init__(self, updating=True, **kwargs): + """Single line Fact entry. + + Processed with Fact.parse, range at "head" position. + + Args: + storage (storage.Storage): + A concrete storage instance, + usually a dbus.client.Storage, + sometimes a storage.db.Storage directly. + """ + def __init__(self, storage, **kwargs): gtk.Entry.__init__(self, **kwargs) # default day for times without date @@ -226,7 +234,7 @@ def __init__(self, updating=True, **kwargs): self.complete_tree.connect("on-click", self.on_tree_click) box.add(self.complete_tree) - self.storage = client.Storage() + self.storage = storage self.load_suggestions() self.ignore_stroke = False @@ -474,10 +482,14 @@ def show_suggestions(self, text): class ActivityEntry(): """Activity entry widget. - widget (gtk.Entry): the associated activity entry - category_widget (gtk.Entry): the associated category entry + Args: + storage (Storage): concrete Storage instance + widget (gtk.Entry): the associated activity entry + category_widget (gtk.Entry): the associated category entry """ - def __init__(self, widget=None, category_widget=None, **kwds): + def __init__(self, storage, widget=None, category_widget=None, **kwds): + self.storage = storage + # widget and completion may be defined already # e.g. in the glade edit_activity.ui file self.widget = widget @@ -585,10 +597,10 @@ def populate_completions(self): category_names = [self.category_widget.get_text()] else: category_names = [category['name'] - for category in runtime.storage.get_categories()] + for category in self.storage.get_categories()] for category_name in category_names: - category_id = runtime.storage.get_category_id(category_name) - activities = runtime.storage.get_category_activities(category_id) + category_id = self.storage.get_category_id(category_name) + activities = self.storage.get_category_activities(category_id) for activity in activities: activity_name = activity["name"] text = "{}@{}".format(activity_name, category_name) @@ -601,9 +613,13 @@ def __getattr__(self, name): class CategoryEntry(): """Category entry widget. - widget (gtk.Entry): the associated category entry + Args: + storage (Storage): concrete Storage instance + widget (gtk.Entry): the associated category entry """ - def __init__(self, widget=None, **kwds): + def __init__(self, storage, widget=None, **kwds): + self.storage = storage + # widget and completion are already defined # e.g. in the glade edit_activity.ui file self.widget = widget @@ -654,7 +670,7 @@ def on_icon_release(self, entry, icon_pos, event): def populate_completions(self): self.model.clear() - for category in runtime.storage.get_categories(): + for category in self.storage.get_categories(): self.model.append([category['name']]) def __getattr__(self, name): diff --git a/src/hamster/widgets/tags.py b/src/hamster/widgets/tags.py index 90bb363b2..20f0f51d9 100644 --- a/src/hamster/widgets/tags.py +++ b/src/hamster/widgets/tags.py @@ -25,15 +25,24 @@ from math import pi from hamster.lib import graphics, stuff -from hamster.lib.configuration import runtime + class TagsEntry(gtk.Entry): + """Tags entry widget. + + Args: + storage (Storage): concrete Storage instance + """ + __gsignals__ = { 'tags-selected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } - def __init__(self): + def __init__(self, storage): gtk.Entry.__init__(self) + + self.storage = storage + self.ac_tags = None # "autocomplete" tags self.filter = None # currently applied filter string self.filter_tags = [] #filtered tags @@ -63,7 +72,7 @@ def __init__(self): self._parent_click_watcher = None # bit lame but works self.external_listeners = [ - (runtime.storage, runtime.storage.connect('tags-changed', self.refresh_ac_tags)) + (self.storage, self.storage.connect('tags-changed', self.refresh_ac_tags)) ] self.show() self.populate_suggestions() @@ -145,7 +154,7 @@ def refresh_activities(self): def populate_suggestions(self): self.ac_tags = self.ac_tags or [tag["name"] for tag in - runtime.storage.get_tags(only_autocomplete=True)] + self.storage.get_tags(only_autocomplete=True)] cursor_tag = self.get_cursor_tag() diff --git a/tests/stuff_test.py b/tests/stuff_test.py index 218c07b71..fa6ab46c2 100644 --- a/tests/stuff_test.py +++ b/tests/stuff_test.py @@ -6,7 +6,7 @@ import unittest import re from hamster.lib import datetime as dt -from hamster.lib.dbus import ( +from hamster.dbus.utilities import ( to_dbus_fact, to_dbus_fact_json, to_dbus_range, @@ -17,6 +17,24 @@ from hamster.lib.fact import Fact +class TestCompatibility(unittest.TestCase): + def test_imports(self): + # moved just after v3.0 (2020-02-23) + import hamster.client + from hamster.lib.dbus import ( + DBusMainLoop, + fact_signature, + from_dbus_date, + from_dbus_fact, + from_dbus_fact_json, + from_dbus_range, + to_dbus_date, + to_dbus_fact, + to_dbus_fact_json, + to_dbus_range, + ) + + class TestFact(unittest.TestCase): def test_range(self):