diff options
author | Barry Warsaw <barry@python.org> | 2015-12-10 16:51:16 -0500 |
---|---|---|
committer | Barry Warsaw <barry@python.org> | 2015-12-10 16:51:16 -0500 |
commit | 37a138198f85750f90c20eb8f0c85093de4b8153 (patch) | |
tree | 1d0493cfddcacb1db997fe331a0b132a4400f275 | |
parent | f50df7e72c8fd373688e6610d6c5111c8ce93900 (diff) |
New upstream release.upstream/0.10.0
-rw-r--r-- | .coveragerc | 5 | ||||
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .travis.yml | 10 | ||||
-rw-r--r-- | CONTRIBUTING.rst | 1 | ||||
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rw-r--r-- | Makefile | 12 | ||||
-rw-r--r-- | NEWS.rst | 38 | ||||
-rw-r--r-- | PKG-INFO | 61 | ||||
-rw-r--r-- | README.rst | 14 | ||||
-rw-r--r-- | docs/index.rst | 15 | ||||
-rw-r--r-- | gtimelog.appdata.xml | 17 | ||||
-rw-r--r-- | gtimelog.rst | 24 | ||||
-rw-r--r-- | gtimelogrc.example | 9 | ||||
-rw-r--r-- | gtimelogrc.rst | 18 | ||||
-rw-r--r-- | setup.cfg | 10 | ||||
-rwxr-xr-x | setup.py | 9 | ||||
-rw-r--r-- | src/gtimelog.egg-info/PKG-INFO (renamed from gtimelog.egg-info/PKG-INFO) | 61 | ||||
-rw-r--r-- | src/gtimelog.egg-info/SOURCES.txt (renamed from gtimelog.egg-info/SOURCES.txt) | 17 | ||||
-rw-r--r-- | src/gtimelog.egg-info/dependency_links.txt (renamed from gtimelog.egg-info/dependency_links.txt) | 0 | ||||
-rw-r--r-- | src/gtimelog.egg-info/entry_points.txt (renamed from gtimelog.egg-info/entry_points.txt) | 0 | ||||
-rw-r--r-- | src/gtimelog.egg-info/not-zip-safe (renamed from gtimelog.egg-info/not-zip-safe) | 0 | ||||
-rw-r--r-- | src/gtimelog.egg-info/pbr.json | 1 | ||||
-rw-r--r-- | src/gtimelog.egg-info/top_level.txt (renamed from gtimelog.egg-info/top_level.txt) | 0 | ||||
-rw-r--r-- | src/gtimelog/__init__.py | 2 | ||||
-rw-r--r-- | src/gtimelog/gtimelog.ui | 172 | ||||
-rw-r--r-- | src/gtimelog/main.py | 784 | ||||
-rw-r--r-- | src/gtimelog/settings.py | 7 | ||||
-rw-r--r-- | src/gtimelog/tests.py | 352 | ||||
-rw-r--r-- | src/gtimelog/timelog.py | 240 | ||||
-rw-r--r-- | tox.ini | 20 |
30 files changed, 1106 insertions, 795 deletions
diff --git a/.coveragerc b/.coveragerc index 5230a5e..32f201d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,8 @@ +[run] +source = src/gtimelog +omit = src/gtimelog/tests.py,src/gtimelog/main.py +cover_pylib = False + [report] exclude_lines = pragma: nocover @@ -12,3 +12,4 @@ gtimelogrc.5 gtimelogrc.sample *~ tags +coverage.xml diff --git a/.travis.yml b/.travis.yml index 8b3d4b9..674e96b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,15 @@ language: python +sudo: false python: - - 2.6 - 2.7 - 3.3 + - 3.4 + - 3.5 install: - - travis_retry pip install . + - pip install freezegun mock coverage coveralls -e . script: - - python setup.py test -q + - coverage run -m gtimelog.tests +after_success: + - coveralls notifications: email: false diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 01d54fd..97bc470 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -46,4 +46,3 @@ Run the test suite with :: $ ./runtests The common ``python setup.py test`` idiom is also supported. - diff --git a/MANIFEST.in b/MANIFEST.in index e8b3cec..7741643 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ include COPYING include *.rst include Makefile include gtimelog +include gtimelog.appdata.xml include gtimelog.desktop include gtimelogrc.example include runtests @@ -25,8 +25,14 @@ check test: .PHONY: coverage coverage: - coverage run ./runtests - coverage report --include 'src/gtimelog/*' + detox -e coverage,coverage3 -- -p + coverage combine + coverage report + +.PHONY: coverage-diff +coverage-diff: coverage + coverage xml + diff-cover coverage.xml .PHONY: clean clean: @@ -79,7 +85,7 @@ release: releasechecklist # I'm chicken so I won't actually do these things yet @echo "Please run" @echo - @echo " $(PYTHON) setup.py sdist register upload && git tag `$(PYTHON) setup.py --version`" + @echo " rm -rf dist && $(PYTHON) setup.py sdist && twine upload dist/* && git tag `$(PYTHON) setup.py --version`" @echo @echo "Please increment the version number in $(FILE_WITH_VERSION)" @echo "and add a new empty entry at the top of $(FILE_WITH_CHANGELOG), then" @@ -1,14 +1,48 @@ Changelog --------- +0.10.0 (2015-09-29) +~~~~~~~~~~~~~~~~~~~ + +* Use Tango colors in the main text buffer (GH: #13). + +* Allow tagging entries (GH: #19) + + - The syntax is ``category: text -- tag1 tag2`` + - Per-tag summaries show up in reports + +* Use GtkApplication instead of own DBus server for enforcing single-instance. + + - Drop --replace, --ignore-dbus command-line options because of this. + - Require glib and gio to be version 2.40 or newer for sane + GtkApplication-based command line parsing + (check with ``pkg-config --modversion glib-2.0 gio-2.0``). + +* Remove obsolete code: + + - Drop support for Python 2.6 (PyGObject dropped support for it long ago). + - Drop PyGtk/Gtk+ 2 support code (it didn't work since 0.9.1 anyway). + - Drop EggTrayIcon support (it was for Gtk+ 2 only anyway). + - Drop the --prefer-pygtk command-line option. + +* Disable tray icon by default for new users (existing gtimelogrc files will be + untouched). + +* Improve tray icon selection logic for best contrast (GH: #29). + + +0.9.3 (2015-09-29) +~~~~~~~~~~~~~~~~~~ + +* Adding new entries didn't update total weekly numbers (GH: #28). + 0.9.2 (2014-09-28) ~~~~~~~~~~~~~~~~~~ -* Fix setup.py to work on Python 3 when your locale is not UTF-8 - (LP: #1263772). * Note that Gtk+ 2.x is no longer supported (this regressed somewhere between 0.9.0 and 0.9.1, but I didn't notice because I have no access to a system that has Gtk+ 2.x). +* Fix setup.py to work on Python 3 when your locale is not UTF-8 (LP: #1263772). * Fix two Gtk-CRITICAL warnings on startup (GH: #14). * Fix Unicode warning when adding entries (GH: #20). * Speed up entry addition (GH: #21). @@ -1,6 +1,6 @@ Metadata-Version: 1.1 Name: gtimelog -Version: 0.9.2 +Version: 0.10.0 Summary: A Gtk+ time tracking application Home-page: http://mg.pov.lt/gtimelog/ Author: Marius Gedminas @@ -9,11 +9,19 @@ License: GPL Description: GTimeLog ======== - .. image:: https://travis-ci.org/gtimelog/gtimelog.png?branch=master + GTimeLog is a simple app for keeping track of time. + + .. image:: https://pypip.in/version/gtimelog/badge.svg?style=flat + :target: https://pypi.python.org/pypi/gtimelog/ + :alt: latest version + + .. image:: https://travis-ci.org/gtimelog/gtimelog.svg?branch=master :target: https://travis-ci.org/gtimelog/gtimelog :alt: build status - GTimeLog is a simple app for keeping track of time. + .. image:: https://coveralls.io/repos/gtimelog/gtimelog/badge.svg?branch=master + :target: https://coveralls.io/r/gtimelog/gtimelog?branch=master + :alt: test coverage .. contents:: @@ -45,7 +53,7 @@ Description: GTimeLog System requirements: - - Python (2.6, 2.7 or 3.3) + - Python (2.7 or 3.3+) - PyGObject - gobject-introspection type libraries for GTK+, Pango @@ -100,14 +108,48 @@ Description: GTimeLog Changelog --------- + 0.10.0 (2015-09-29) + ~~~~~~~~~~~~~~~~~~~ + + * Use Tango colors in the main text buffer (GH: #13). + + * Allow tagging entries (GH: #19) + + - The syntax is ``category: text -- tag1 tag2`` + - Per-tag summaries show up in reports + + * Use GtkApplication instead of own DBus server for enforcing single-instance. + + - Drop --replace, --ignore-dbus command-line options because of this. + - Require glib and gio to be version 2.40 or newer for sane + GtkApplication-based command line parsing + (check with ``pkg-config --modversion glib-2.0 gio-2.0``). + + * Remove obsolete code: + + - Drop support for Python 2.6 (PyGObject dropped support for it long ago). + - Drop PyGtk/Gtk+ 2 support code (it didn't work since 0.9.1 anyway). + - Drop EggTrayIcon support (it was for Gtk+ 2 only anyway). + - Drop the --prefer-pygtk command-line option. + + * Disable tray icon by default for new users (existing gtimelogrc files will be + untouched). + + * Improve tray icon selection logic for best contrast (GH: #29). + + + 0.9.3 (2015-09-29) + ~~~~~~~~~~~~~~~~~~ + + * Adding new entries didn't update total weekly numbers (GH: #28). + 0.9.2 (2014-09-28) ~~~~~~~~~~~~~~~~~~ - * Fix setup.py to work on Python 3 when your locale is not UTF-8 - (LP: #1263772). * Note that Gtk+ 2.x is no longer supported (this regressed somewhere between 0.9.0 and 0.9.1, but I didn't notice because I have no access to a system that has Gtk+ 2.x). + * Fix setup.py to work on Python 3 when your locale is not UTF-8 (LP: #1263772). * Fix two Gtk-CRITICAL warnings on startup (GH: #14). * Fix Unicode warning when adding entries (GH: #20). * Speed up entry addition (GH: #21). @@ -118,11 +160,6 @@ Description: GTimeLog on Python 3 (GH: #26). - 0.9.1 (2013-12-23) - ~~~~~~~~~~~~~~~~~~ - * Manual pages for gtimelog(1) and gtimelogrc(5). - - Older versions ~~~~~~~~~~~~~~ @@ -136,4 +173,6 @@ Classifier: Environment :: X11 Applications :: GTK Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 Classifier: Topic :: Office/Business @@ -1,11 +1,19 @@ GTimeLog ======== -.. image:: https://travis-ci.org/gtimelog/gtimelog.png?branch=master +GTimeLog is a simple app for keeping track of time. + +.. image:: https://pypip.in/version/gtimelog/badge.svg?style=flat + :target: https://pypi.python.org/pypi/gtimelog/ + :alt: latest version + +.. image:: https://travis-ci.org/gtimelog/gtimelog.svg?branch=master :target: https://travis-ci.org/gtimelog/gtimelog :alt: build status -GTimeLog is a simple app for keeping track of time. +.. image:: https://coveralls.io/repos/gtimelog/gtimelog/badge.svg?branch=master + :target: https://coveralls.io/r/gtimelog/gtimelog?branch=master + :alt: test coverage .. contents:: @@ -37,7 +45,7 @@ You can run it from a source checkout without an explicit installation step:: System requirements: -- Python (2.6, 2.7 or 3.3) +- Python (2.7 or 3.3+) - PyGObject - gobject-introspection type libraries for GTK+, Pango diff --git a/docs/index.rst b/docs/index.rst index bc7b630..94fc4f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,6 +53,21 @@ Work activities can also include a category name, e.g.:: The tasks are grouped by category in the reports. +Each entry may be additionally labelled with multiple +(space-separated) tags, e.g.:: + + project3: upgrade webserver -- sysadmin www front-end + project3: restart mail server -- sysadmin mail + +Reports will then include an additional breakdown by tag: for each +tag, the total time spent in entries marked with that tag is shown. +Note that these times will (likely) not add up to the total reporting +time, as each entry may be marked with several tags. + +Tags must be separated from the rest of the entry by `` -- ``, i.e., +double-dash surrounded by spaces. Tags will *not* be shown in the +main UI pane. + Tasks Pane ========== diff --git a/gtimelog.appdata.xml b/gtimelog.appdata.xml new file mode 100644 index 0000000..06aaf9d --- /dev/null +++ b/gtimelog.appdata.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<application> + <id type="desktop">gtimelog.desktop</id> + <metadata_license>CC0-1.0</metadata_license> + <project_license>GPL-2.0</project_license> + + <name>GTimeLog</name> + <summary>Unobtrusively keep track of your time</summary> + <description> + <p>GTimeLog is a small GTK+ app for keeping track of your time. Its main goal is to be as unobtrusive as possible.</p> + </description> + <screenshots> + <screenshot width="802" height="533" type="default">https://mg.pov.lt/gtimelog/gtimelog-gtk3.png</screenshot> + </screenshots> + <url type="homepage">https://mg.pov.lt/gtimelog/</url> + <updatecontact>marius@gedmin.as</updatecontact> +</application> diff --git a/gtimelog.rst b/gtimelog.rst index 43a7095..e324b13 100644 --- a/gtimelog.rst +++ b/gtimelog.rst @@ -7,9 +7,9 @@ minimal time logging application -------------------------------- :Author: Marius Gedminas <mgedmin@gedmin.as> -:Date: 2013-12-23 +:Date: 2014-03-19 :Copyright: Marius Gedminas -:Version: 0.9.1 +:Version: 0.10 :Manual section: 1 @@ -93,29 +93,9 @@ OPTIONS --sample-config Write a sample configuration file to 'gtimelogrc.sample'. -Single-Instance Options: - ---replace - Replace the already running ``gtimelog`` instance. - ---quit - Tell an already-running ``gtimelog`` instance to quit. - ---toggle - Show/hide the ``gtimelog`` window if already running. - ---ignore-dbus - Do not check if ``gtimelog`` is already running (allows you to have - multiple instances running). - -Debugging Options: - --debug Show debug information. ---prefer-pygtk - Try to use the (obsolete) pygtk library instead of pygi. - FILES ===== diff --git a/gtimelogrc.example b/gtimelogrc.example index 94421bb..50fe934 100644 --- a/gtimelogrc.example +++ b/gtimelogrc.example @@ -18,17 +18,14 @@ mailer = x-terminal-emulator -e mutt -H %s # file; if there is no '%s', the name of the log file is appended. editor = gvim -# User interface: True enables drop-down history completion (if you have PyGtk -# 2.4), False disables and lets you access history by pressing Up/Down. +# User interface: True enables drop-down history completion, False disables and +# lets you access history by pressing Up/Down. (You can always use the non-Gtk +# completion by pressing PageUp/PageDown.) gtk-completion = False # Do you want a systray icon? show_tray_icon = yes -# Do you prefer the old systray icon (that shows time taken for the current -# task next to the icon), or the new one (just the icon)? -prefer_old_tray_icon = yes - # How many hours' work in a day. hours = 8 diff --git a/gtimelogrc.rst b/gtimelogrc.rst index 8221da6..9464b4d 100644 --- a/gtimelogrc.rst +++ b/gtimelogrc.rst @@ -184,36 +184,26 @@ show_tray_icon Example: ``show_tray_icon = True`` -prefer_app_indicator, prefer_old_tray_icon +prefer_app_indicator what kind of tray icon do you prefer? - GTimeLog supports three kinds: + GTimeLog supports two kinds: - Unity application indicator - a standard Gtk+ status icon - - ancient EggTrayIcon that shows a ticking clock next to the icon Support for each is conditional on the availability of installed libraries. Example:: # prefer Unity application indicators, then fall back to the Gtk+ - # status icon, then fall back to EggTrayIcon. + # status icon. prefer_app_indicator = True Example:: - # prefer the ancient EggTrayIcon, then fall back to the Gtk+ - # status icon, then fall back to Unity app indicator. + # prefer the Gtk+ status icon, then fall back to Unity app indicator. prefer_app_indicator = False - prefer_old_tray_icon = True - - Example:: - - # prefer the Gtk+ status icon, then fall back to the ancient - # EggTrayIcon, then fall back to Unity app indicator. - prefer_app_indicator = False - prefer_old_tray_icon = False start_in_tray whether GTimeLog should start minimized @@ -1,3 +1,13 @@ +[flake8] +doctests = yes + +[pytest] +norecursedirs = .* *.egg-info dist build tmp scripts +python_files = tests.py +python_functions = !test_suite +addopts = --doctest-modules --ignore=setup.py +doctest_optionflags = NORMALIZE_WHITESPACE + [egg_info] tag_build = tag_date = 0 @@ -55,15 +55,16 @@ setup( 'License :: OSI Approved :: GNU General Public License (GPL)', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.3', - # 2.6 might work, but I can't test it myself -- recent - # python-gobject versions dropped support for Python 2.6 + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Topic :: Office/Business', ], packages=['gtimelog'], - package_dir={'gtimelog': 'src/gtimelog'}, + package_dir={'': 'src'}, package_data={'gtimelog': ['*.ui', '*.png']}, test_suite='gtimelog.tests', + tests_require=['freezegun', 'mock'], zip_safe=False, entry_points=""" [gui_scripts] @@ -71,5 +72,5 @@ setup( """, # This is true, but pointless, because PyGObject cannot be installed via # setuptools/distutils -# install_requires=['PyGObject'], # or PyGTK +# install_requires=['PyGObject'], ) diff --git a/gtimelog.egg-info/PKG-INFO b/src/gtimelog.egg-info/PKG-INFO index bf6fae7..0697490 100644 --- a/gtimelog.egg-info/PKG-INFO +++ b/src/gtimelog.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 1.1 Name: gtimelog -Version: 0.9.2 +Version: 0.10.0 Summary: A Gtk+ time tracking application Home-page: http://mg.pov.lt/gtimelog/ Author: Marius Gedminas @@ -9,11 +9,19 @@ License: GPL Description: GTimeLog ======== - .. image:: https://travis-ci.org/gtimelog/gtimelog.png?branch=master + GTimeLog is a simple app for keeping track of time. + + .. image:: https://pypip.in/version/gtimelog/badge.svg?style=flat + :target: https://pypi.python.org/pypi/gtimelog/ + :alt: latest version + + .. image:: https://travis-ci.org/gtimelog/gtimelog.svg?branch=master :target: https://travis-ci.org/gtimelog/gtimelog :alt: build status - GTimeLog is a simple app for keeping track of time. + .. image:: https://coveralls.io/repos/gtimelog/gtimelog/badge.svg?branch=master + :target: https://coveralls.io/r/gtimelog/gtimelog?branch=master + :alt: test coverage .. contents:: @@ -45,7 +53,7 @@ Description: GTimeLog System requirements: - - Python (2.6, 2.7 or 3.3) + - Python (2.7 or 3.3+) - PyGObject - gobject-introspection type libraries for GTK+, Pango @@ -100,14 +108,48 @@ Description: GTimeLog Changelog --------- + 0.10.0 (2015-09-29) + ~~~~~~~~~~~~~~~~~~~ + + * Use Tango colors in the main text buffer (GH: #13). + + * Allow tagging entries (GH: #19) + + - The syntax is ``category: text -- tag1 tag2`` + - Per-tag summaries show up in reports + + * Use GtkApplication instead of own DBus server for enforcing single-instance. + + - Drop --replace, --ignore-dbus command-line options because of this. + - Require glib and gio to be version 2.40 or newer for sane + GtkApplication-based command line parsing + (check with ``pkg-config --modversion glib-2.0 gio-2.0``). + + * Remove obsolete code: + + - Drop support for Python 2.6 (PyGObject dropped support for it long ago). + - Drop PyGtk/Gtk+ 2 support code (it didn't work since 0.9.1 anyway). + - Drop EggTrayIcon support (it was for Gtk+ 2 only anyway). + - Drop the --prefer-pygtk command-line option. + + * Disable tray icon by default for new users (existing gtimelogrc files will be + untouched). + + * Improve tray icon selection logic for best contrast (GH: #29). + + + 0.9.3 (2015-09-29) + ~~~~~~~~~~~~~~~~~~ + + * Adding new entries didn't update total weekly numbers (GH: #28). + 0.9.2 (2014-09-28) ~~~~~~~~~~~~~~~~~~ - * Fix setup.py to work on Python 3 when your locale is not UTF-8 - (LP: #1263772). * Note that Gtk+ 2.x is no longer supported (this regressed somewhere between 0.9.0 and 0.9.1, but I didn't notice because I have no access to a system that has Gtk+ 2.x). + * Fix setup.py to work on Python 3 when your locale is not UTF-8 (LP: #1263772). * Fix two Gtk-CRITICAL warnings on startup (GH: #14). * Fix Unicode warning when adding entries (GH: #20). * Speed up entry addition (GH: #21). @@ -118,11 +160,6 @@ Description: GTimeLog on Python 3 (GH: #26). - 0.9.1 (2013-12-23) - ~~~~~~~~~~~~~~~~~~ - * Manual pages for gtimelog(1) and gtimelogrc(5). - - Older versions ~~~~~~~~~~~~~~ @@ -136,4 +173,6 @@ Classifier: Environment :: X11 Applications :: GTK Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 Classifier: Topic :: Office/Business diff --git a/gtimelog.egg-info/SOURCES.txt b/src/gtimelog.egg-info/SOURCES.txt index 705b74e..f62f520 100644 --- a/gtimelog.egg-info/SOURCES.txt +++ b/src/gtimelog.egg-info/SOURCES.txt @@ -11,22 +11,18 @@ NOTES.rst README.rst TODO.rst gtimelog +gtimelog.appdata.xml gtimelog.desktop gtimelog.rst gtimelogrc.example gtimelogrc.rst runtests +setup.cfg setup.py tox.ini docs/formats.rst docs/gtimelog.png docs/index.rst -gtimelog.egg-info/PKG-INFO -gtimelog.egg-info/SOURCES.txt -gtimelog.egg-info/dependency_links.txt -gtimelog.egg-info/entry_points.txt -gtimelog.egg-info/not-zip-safe -gtimelog.egg-info/top_level.txt scripts/README.rst scripts/difftime.py scripts/export-my-calendar.py @@ -43,4 +39,11 @@ src/gtimelog/gtimelog.ui src/gtimelog/main.py src/gtimelog/settings.py src/gtimelog/tests.py -src/gtimelog/timelog.py
\ No newline at end of file +src/gtimelog/timelog.py +src/gtimelog.egg-info/PKG-INFO +src/gtimelog.egg-info/SOURCES.txt +src/gtimelog.egg-info/dependency_links.txt +src/gtimelog.egg-info/entry_points.txt +src/gtimelog.egg-info/not-zip-safe +src/gtimelog.egg-info/pbr.json +src/gtimelog.egg-info/top_level.txt
\ No newline at end of file diff --git a/gtimelog.egg-info/dependency_links.txt b/src/gtimelog.egg-info/dependency_links.txt index 8b13789..8b13789 100644 --- a/gtimelog.egg-info/dependency_links.txt +++ b/src/gtimelog.egg-info/dependency_links.txt diff --git a/gtimelog.egg-info/entry_points.txt b/src/gtimelog.egg-info/entry_points.txt index 387b608..387b608 100644 --- a/gtimelog.egg-info/entry_points.txt +++ b/src/gtimelog.egg-info/entry_points.txt diff --git a/gtimelog.egg-info/not-zip-safe b/src/gtimelog.egg-info/not-zip-safe index 8b13789..8b13789 100644 --- a/gtimelog.egg-info/not-zip-safe +++ b/src/gtimelog.egg-info/not-zip-safe diff --git a/src/gtimelog.egg-info/pbr.json b/src/gtimelog.egg-info/pbr.json new file mode 100644 index 0000000..0f68d7c --- /dev/null +++ b/src/gtimelog.egg-info/pbr.json @@ -0,0 +1 @@ +{"is_release": false, "git_version": "7ed2060"}
\ No newline at end of file diff --git a/gtimelog.egg-info/top_level.txt b/src/gtimelog.egg-info/top_level.txt index 72f1082..72f1082 100644 --- a/gtimelog.egg-info/top_level.txt +++ b/src/gtimelog.egg-info/top_level.txt diff --git a/src/gtimelog/__init__.py b/src/gtimelog/__init__.py index e60065b..997e093 100644 --- a/src/gtimelog/__init__.py +++ b/src/gtimelog/__init__.py @@ -1,3 +1,3 @@ # The gtimelog package. -__version__ = '0.9.2' +__version__ = '0.10.0' diff --git a/src/gtimelog/gtimelog.ui b/src/gtimelog/gtimelog.ui index b7e4dae..37f9a37 100644 --- a/src/gtimelog/gtimelog.ui +++ b/src/gtimelog/gtimelog.ui @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="UTF-8"?> -<!-- Generated with glade 3.16.1 --> +<!-- Generated with glade 3.18.1 --> <interface> - <requires lib="gtk+" version="3.0"/> + <requires lib="gtk+" version="3.10"/> <object class="GtkDialog" id="about_dialog"> <property name="can_focus">False</property> <property name="title" translatable="yes">About TimeLog</property> @@ -19,12 +19,12 @@ <property name="layout_style">end</property> <child> <object class="GtkButton" id="ok_button"> - <property name="label">gtk-ok</property> + <property name="label" translatable="yes">_OK</property> <property name="visible">True</property> <property name="can_focus">True</property> <property name="can_default">True</property> <property name="receives_default">False</property> - <property name="use_stock">True</property> + <property name="use_underline">True</property> </object> <packing> <property name="expand">False</property> @@ -41,10 +41,11 @@ </packing> </child> <child> - <object class="GtkVBox" id="vbox2"> + <object class="GtkBox" id="vbox2"> <property name="visible">True</property> <property name="can_focus">False</property> <property name="border_width">16</property> + <property name="orientation">vertical</property> <child> <object class="GtkLabel" id="about_text"> <property name="visible">True</property> @@ -95,12 +96,11 @@ GTimeLog is a time tracking application. </object> </child> <child> - <object class="GtkImageMenuItem" id="appind_quit"> - <property name="label">gtk-quit</property> + <object class="GtkMenuItem" id="appind_quit"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Quit</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_quit_activate" swapped="no"/> </object> </child> @@ -122,12 +122,12 @@ GTimeLog is a time tracking application. <property name="layout_style">end</property> <child> <object class="GtkButton" id="cancelbutton1"> - <property name="label">gtk-cancel</property> + <property name="label" translatable="yes">_Cancel</property> <property name="visible">True</property> <property name="can_focus">True</property> <property name="can_default">True</property> <property name="receives_default">False</property> - <property name="use_stock">True</property> + <property name="use_underline">True</property> </object> <packing> <property name="expand">False</property> @@ -137,13 +137,13 @@ GTimeLog is a time tracking application. </child> <child> <object class="GtkButton" id="okbutton1"> - <property name="label">gtk-ok</property> + <property name="label" translatable="yes">_OK</property> <property name="visible">True</property> <property name="can_focus">True</property> <property name="can_default">True</property> <property name="has_default">True</property> <property name="receives_default">False</property> - <property name="use_stock">True</property> + <property name="use_underline">True</property> </object> <packing> <property name="expand">False</property> @@ -186,9 +186,10 @@ GTimeLog is a time tracking application. <property name="default_height">500</property> <property name="icon">gtimelog.png</property> <child> - <object class="GtkVBox" id="vbox1"> + <object class="GtkBox" id="vbox1"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="orientation">vertical</property> <child> <object class="GtkMenuBar" id="main_menu"> <property name="visible">True</property> @@ -203,34 +204,31 @@ GTimeLog is a time tracking application. <object class="GtkMenu" id="menuitem1_menu"> <property name="can_focus">False</property> <child> - <object class="GtkImageMenuItem" id="reload"> - <property name="label">_Reload</property> + <object class="GtkMenuItem" id="reload"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Reload</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_reread_activate" swapped="no"/> <accelerator key="R" signal="activate" modifiers="GDK_CONTROL_MASK"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="edit_timelog"> - <property name="label">_Edit timelog.txt</property> + <object class="GtkMenuItem" id="edit_timelog"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Edit timelog.txt</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_edit_timelog_activate" swapped="no"/> <accelerator key="E" signal="activate" modifiers="GDK_CONTROL_MASK"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="main_quit"> - <property name="label">gtk-quit</property> + <object class="GtkMenuItem" id="main_quit"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Quit</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_quit_activate" swapped="no"/> <accelerator key="q" signal="activate" modifiers="GDK_CONTROL_MASK"/> </object> @@ -265,9 +263,9 @@ GTimeLog is a time tracking application. <property name="label" translatable="yes">_Grouped</property> <property name="use_underline">True</property> <property name="active">True</property> + <property name="group">chronological</property> <signal name="activate" handler="on_grouped_activate" swapped="no"/> <accelerator key="2" signal="activate" modifiers="GDK_MOD1_MASK"/> - <property name="group">chronological</property> </object> </child> <child> @@ -276,9 +274,9 @@ GTimeLog is a time tracking application. <property name="can_focus">False</property> <property name="label" translatable="yes">_Summary</property> <property name="use_underline">True</property> + <property name="group">chronological</property> <signal name="activate" handler="on_summary_activate" swapped="no"/> <accelerator key="3" signal="activate" modifiers="GDK_MOD1_MASK"/> - <property name="group">chronological</property> </object> </child> <child> @@ -313,103 +311,93 @@ GTimeLog is a time tracking application. <object class="GtkMenu" id="menuitem2_menu"> <property name="can_focus">False</property> <child> - <object class="GtkImageMenuItem" id="daily_report"> - <property name="label">_Daily Report</property> + <object class="GtkMenuItem" id="daily_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Daily Report</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_daily_report_activate" swapped="no"/> <accelerator key="D" signal="activate" modifiers="GDK_CONTROL_MASK"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="yesterdays_report"> - <property name="label">Daily Report for _Yesterday</property> + <object class="GtkMenuItem" id="yesterdays_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Daily Report for _Yesterday</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_yesterdays_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="previous_day_report"> - <property name="label">Daily Report for a _Previous Day...</property> + <object class="GtkMenuItem" id="previous_day_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Daily Report for a _Previous Day...</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_previous_day_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="weekly_report"> - <property name="label">_Weekly Report</property> + <object class="GtkMenuItem" id="weekly_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Weekly Report</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_weekly_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="last_weeks_report"> - <property name="label">Weekly Report for _Last Week</property> + <object class="GtkMenuItem" id="last_weeks_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Weekly Report for _Last Week</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_last_weeks_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="previous_week_report"> - <property name="label">Weekly Report for a Pre_vious Week...</property> + <object class="GtkMenuItem" id="previous_week_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Weekly Report for a Pre_vious Week...</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_previous_week_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="monthly_report"> - <property name="label">_Monthly Report</property> + <object class="GtkMenuItem" id="monthly_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Monthly Report</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_monthly_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="last_month_report"> - <property name="label">Monthly Report for Last Month</property> + <object class="GtkMenuItem" id="last_month_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Monthly Report for Last Month</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_last_month_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="previous_month_report"> - <property name="label">Monthly Report for a Previous Month...</property> + <object class="GtkMenuItem" id="previous_month_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Monthly Report for a Previous Month...</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_previous_month_report_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="custom_range_report"> - <property name="label">Report for a Custom Date Range...</property> + <object class="GtkMenuItem" id="custom_range_report"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Report for a Custom Date Range...</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_custom_range_report_activate" swapped="no"/> </object> </child> @@ -420,22 +408,20 @@ GTimeLog is a time tracking application. </object> </child> <child> - <object class="GtkImageMenuItem" id="open_complete_spreadsheet"> - <property name="label">_Complete Report in Spreadsheet</property> + <object class="GtkMenuItem" id="open_complete_spreadsheet"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Complete Report in Spreadsheet</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_open_complete_spreadsheet_activate" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="open_slack_spreadsheet"> - <property name="label">Work/_Slacking stats in Spreadsheet</property> + <object class="GtkMenuItem" id="open_slack_spreadsheet"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">Work/_Slacking stats in Spreadsheet</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_open_slack_spreadsheet_activate" swapped="no"/> </object> </child> @@ -453,12 +439,11 @@ GTimeLog is a time tracking application. <object class="GtkMenu" id="menuitem4_menu"> <property name="can_focus">False</property> <child> - <object class="GtkImageMenuItem" id="about"> - <property name="label">gtk-about</property> + <object class="GtkMenuItem" id="about"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_About</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_about_activate" swapped="no"/> </object> </child> @@ -484,15 +469,16 @@ GTimeLog is a time tracking application. </packing> </child> <child> - <object class="GtkHPaned" id="hpaned1"> + <object class="GtkPaned" id="hpaned1"> <property name="visible">True</property> <property name="can_focus">True</property> <property name="position">600</property> <property name="position_set">True</property> <child> - <object class="GtkVBox" id="vbox3"> + <object class="GtkBox" id="vbox3"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="orientation">vertical</property> <child> <object class="GtkToolbar" id="toolbar"> <property name="visible">True</property> @@ -503,7 +489,7 @@ GTimeLog is a time tracking application. <property name="can_focus">False</property> <property name="label" translatable="yes">Back</property> <property name="use_underline">True</property> - <property name="stock_id">gtk-go-back</property> + <property name="icon_name">go-previous</property> <signal name="clicked" handler="on_back_toolbutton_clicked" swapped="no"/> <accelerator key="Left" signal="clicked" modifiers="GDK_MOD1_MASK"/> </object> @@ -520,7 +506,6 @@ GTimeLog is a time tracking application. <object class="GtkLabel" id="current_view_label"> <property name="visible">True</property> <property name="can_focus">False</property> - <property name="xpad">6</property> <property name="label" translatable="yes">Tuesday, 2012-01-31 (week 05)</property> </object> </child> @@ -536,7 +521,7 @@ GTimeLog is a time tracking application. <property name="can_focus">False</property> <property name="label" translatable="yes">Forward</property> <property name="use_underline">True</property> - <property name="stock_id">gtk-go-forward</property> + <property name="icon_name">go-next</property> <signal name="clicked" handler="on_forward_toolbutton_clicked" swapped="no"/> <accelerator key="Right" signal="clicked" modifiers="GDK_MOD1_MASK"/> </object> @@ -550,9 +535,9 @@ GTimeLog is a time tracking application. <property name="visible">True</property> <property name="can_focus">False</property> <property name="tooltip_text" translatable="yes">Today</property> - <property name="label" translatable="yes">toolbutton2</property> + <property name="label" translatable="yes">Today</property> <property name="use_underline">True</property> - <property name="stock_id">gtk-goto-last</property> + <property name="icon_name">go-last</property> <signal name="clicked" handler="on_today_toolbutton_clicked" swapped="no"/> <accelerator key="Home" signal="clicked" modifiers="GDK_MOD1_MASK"/> </object> @@ -598,14 +583,16 @@ GTimeLog is a time tracking application. </packing> </child> <child> - <object class="GtkVBox" id="task_list_pane"> + <object class="GtkBox" id="task_list_pane"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="orientation">vertical</property> <property name="spacing">6</property> <child> - <object class="GtkVBox" id="task_list_pane_outer_vbox"> + <object class="GtkBox" id="task_list_pane_outer_vbox"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="orientation">vertical</property> <child> <object class="GtkToolbar" id="task_pane_toolbar"> <property name="visible">True</property> @@ -618,11 +605,11 @@ GTimeLog is a time tracking application. <object class="GtkLabel" id="task_pane_title_label"> <property name="visible">True</property> <property name="can_focus">False</property> - <property name="xalign">0</property> <property name="label" translatable="yes">_Tasks</property> <property name="use_markup">True</property> <property name="use_underline">True</property> <property name="mnemonic_widget">task_list</property> + <property name="xalign">0</property> </object> </child> </object> @@ -635,9 +622,9 @@ GTimeLog is a time tracking application. <object class="GtkToolButton" id="task_pane_close_toolbutton"> <property name="visible">True</property> <property name="can_focus">False</property> - <property name="label" translatable="yes">toolbutton2</property> + <property name="label" translatable="yes">Close</property> <property name="use_underline">True</property> - <property name="stock_id">gtk-close</property> + <property name="icon_name">window-close</property> <signal name="clicked" handler="on_task_pane_close_button_activate" swapped="no"/> </object> <packing> @@ -653,14 +640,15 @@ GTimeLog is a time tracking application. </packing> </child> <child> - <object class="GtkHBox" id="task_pane_hbox"> + <object class="GtkBox" id="task_pane_hbox"> <property name="visible">True</property> <property name="can_focus">False</property> <property name="spacing">6</property> <child> - <object class="GtkVBox" id="task_list_pane_vbox"> + <object class="GtkBox" id="task_list_pane_vbox"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="orientation">vertical</property> <property name="spacing">6</property> <child> <object class="GtkScrolledWindow" id="scrolledwindow2"> @@ -703,9 +691,10 @@ GTimeLog is a time tracking application. </packing> </child> <child> - <object class="GtkVBox" id="task_list_pane_hack_vbox"> + <object class="GtkBox" id="task_list_pane_hack_vbox"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="orientation">vertical</property> <child> <placeholder/> </child> @@ -744,7 +733,7 @@ GTimeLog is a time tracking application. </packing> </child> <child> - <object class="GtkHBox" id="hbox1"> + <object class="GtkBox" id="hbox1"> <property name="visible">True</property> <property name="can_focus">False</property> <property name="border_width">4</property> @@ -753,7 +742,7 @@ GTimeLog is a time tracking application. <object class="GtkLabel" id="time_label"> <property name="visible">True</property> <property name="can_focus">False</property> - <property name="label" translatable="yes">00:12</property> + <property name="label">00:12</property> <property name="mnemonic_widget">task_entry</property> </object> <packing> @@ -806,22 +795,20 @@ GTimeLog is a time tracking application. <object class="GtkMenu" id="task_list_popup_menu"> <property name="can_focus">False</property> <child> - <object class="GtkImageMenuItem" id="task_list_reload"> - <property name="label">gtk-refresh</property> + <object class="GtkMenuItem" id="task_list_reload"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Reload</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_task_list_reload" swapped="no"/> </object> </child> <child> - <object class="GtkImageMenuItem" id="task_list_edit"> - <property name="label">gtk-edit</property> + <object class="GtkMenuItem" id="task_list_edit"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Edit</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_task_list_edit" swapped="no"/> </object> </child> @@ -847,12 +834,11 @@ GTimeLog is a time tracking application. </object> </child> <child> - <object class="GtkImageMenuItem" id="popup_quit"> - <property name="label">gtk-quit</property> + <object class="GtkMenuItem" id="popup_quit"> <property name="visible">True</property> <property name="can_focus">False</property> + <property name="label" translatable="yes">_Quit</property> <property name="use_underline">True</property> - <property name="use_stock">True</property> <signal name="activate" handler="on_quit_activate" swapped="no"/> </object> </child> @@ -874,12 +860,12 @@ GTimeLog is a time tracking application. <property name="layout_style">end</property> <child> <object class="GtkButton" id="cancelbutton2"> - <property name="label">gtk-cancel</property> + <property name="label" translatable="yes">_Cancel</property> <property name="visible">True</property> <property name="can_focus">True</property> <property name="can_default">True</property> <property name="receives_default">True</property> - <property name="use_stock">True</property> + <property name="use_underline">True</property> </object> <packing> <property name="expand">False</property> @@ -889,13 +875,13 @@ GTimeLog is a time tracking application. </child> <child> <object class="GtkButton" id="okbutton2"> - <property name="label">gtk-ok</property> + <property name="label" translatable="yes">_OK</property> <property name="visible">True</property> <property name="can_focus">True</property> <property name="can_default">True</property> <property name="has_default">True</property> <property name="receives_default">True</property> - <property name="use_stock">True</property> + <property name="use_underline">True</property> </object> <packing> <property name="expand">False</property> diff --git a/src/gtimelog/main.py b/src/gtimelog/main.py index 32ce612..b1ff22b 100644 --- a/src/gtimelog/main.py +++ b/src/gtimelog/main.py @@ -4,14 +4,12 @@ __metaclass__ = type import os -import re import sys import errno import codecs import signal import logging import datetime -import optparse import tempfile import gtimelog @@ -25,94 +23,15 @@ try: except NameError: unicode = str - -# Which Gnome toolkit should we use? Prior to 0.7, pygtk was the default with -# a fallback to gi (gobject introspection), except on Ubuntu where gi was -# forced. With 0.7, gi was made the default in upstream, so the Ubuntu -# specific patch isn't necessary. - -if '--prefer-pygtk' in sys.argv: - sys.argv.remove('--prefer-pygtk') - try: - import pygtk - toolkit = 'pygtk' - except ImportError: - try: - import gi - toolkit = 'gi' - except ImportError: - sys.exit("Please install pygobject or pygtk") -else: - try: - import gi - toolkit = 'gi' - except ImportError: - try: - import pygtk - toolkit = 'pygtk' - except ImportError: - sys.exit("Please install pygobject or pygtk") - - -if toolkit == 'gi': - from gi.repository import GObject as gobject - from gi.repository import Gdk as gdk - from gi.repository import Gtk as gtk - from gi.repository import Pango as pango - # These are hacks until we fully switch to GI. - try: - PANGO_ALIGN_LEFT = pango.TabAlign.LEFT - except AttributeError: - # Backwards compatible for older Pango versions with broken GIR. - PANGO_ALIGN_LEFT = pango.TabAlign.TAB_LEFT - GTK_RESPONSE_OK = gtk.ResponseType.OK - gtk_status_icon_new = gtk.StatusIcon.new_from_file - pango_tabarray_new = pango.TabArray.new - - if gtk._version.startswith('2'): - gtk_version = 2 - else: - gtk_version = 3 - - try: - if gtk._version.startswith('2'): - from gi.repository import AppIndicator - else: - from gi.repository import AppIndicator3 as AppIndicator - new_app_indicator = AppIndicator.Indicator.new - APPINDICATOR_CATEGORY = ( - AppIndicator.IndicatorCategory.APPLICATION_STATUS) - APPINDICATOR_ACTIVE = AppIndicator.IndicatorStatus.ACTIVE - except (ImportError, gi._gi.RepositoryError): - new_app_indicator = None -else: - pygtk.require('2.0') - import gobject - import gtk - from gtk import gdk as gdk - import pango - - gtk_version = 2 - PANGO_ALIGN_LEFT = pango.TAB_LEFT - GTK_RESPONSE_OK = gtk.RESPONSE_OK - gtk_status_icon_new = gtk.status_icon_new_from_file - pango_tabarray_new = pango.TabArray - - try: - import appindicator - new_app_indicator = appindicator.Indicator - APPINDICATOR_CATEGORY = appindicator.CATEGORY_APPLICATION_STATUS - APPINDICATOR_ACTIVE = appindicator.STATUS_ACTIVE - except ImportError: - # apt-get install python-appindicator on Ubuntu - new_app_indicator = None +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import GObject, GLib, Gdk, Gio, Gtk, Pango try: - import dbus - import dbus.service - import dbus.mainloop.glib + from gi.repository import AppIndicator3 + have_app_indicator = True except ImportError: - dbus = None + have_app_indicator = False from gtimelog import __version__ @@ -134,32 +53,70 @@ if not os.path.exists(icon_file_bright): from gtimelog.settings import Settings from gtimelog.timelog import ( - format_duration, format_duration_short, uniq, + format_duration, uniq, Reports, TimeLog, TaskList, RemoteTaskList) class IconChooser: + """Picks the right icon for dark or bright panel backgrounds. + + Well, tries to pick it. I couldn't find a way to determine the color of + the panel, so I cheated by assuming it'll be the same as the color of + the menu bar. This is wrong for many popular themes, including: + + - Adwaita + - Radiance + - Ambiance + + which is why I have to maintain a list of per-theme overrides. + """ + + icon_for_background = dict( + # We want sufficient contrast, so: + # - use dark icon for bright backgrounds + # - use bright icon for dark backgrounds + bright=icon_file_dark, + dark=icon_file_bright, + ) + + theme_overrides = { + # when the menu bar color logic gets this wrong + 'Adwaita': 'dark', # but probably only under gnome-shell + 'Ambiance': 'dark', + 'Radiance': 'bright', + } @property def icon_name(self): - # XXX assumes the panel's color matches a menu bar's color, which is - # not necessarily the case! this logic works for, say, - # Ambiance/Radiance, but it gets New Wave and Dark Room wrong. - if toolkit == 'gi': - menu_bar = gtk.MenuBar() - # need to hold a reference to menu_bar to avoid LP#1016212 - style = menu_bar.get_style_context() - color = style.get_color(gtk.StateFlags.NORMAL) - value = (color.red + color.green + color.blue) / 3 - else: - style = gtk.MenuBar().rc_get_style() - color = style.text[gtk.STATE_NORMAL] - value = color.value - filename = icon_file_bright if value >= 0.5 else icon_file_dark - log.debug('Menu bar color: (%g, %g, %g), averages to %g; picking %s', - color.red, color.green, color.blue, value, filename) + theme_name = self.get_gtk_theme() + background = self.get_background() + if theme_name in self.theme_overrides: + background = self.theme_overrides[theme_name] + log.debug('Overriding background to %s for %s', background, theme_name) + filename = self.icon_for_background[background] + log.debug('For %s background picking icon %s', background, filename) return filename + def get_gtk_theme(self): + theme_name = Gtk.Settings.get_default().props.gtk_theme_name + log.debug('GTK+ theme: %s', theme_name) + override = os.environ.get('GTK_THEME') + if override: + log.debug('GTK_THEME overrides the theme to %s', override) + theme_name = override.partition(':')[0] + return theme_name + + def get_background(self): + menu_bar = Gtk.MenuBar() + # need to hold a reference to menu_bar to avoid LP#1016212 + style = menu_bar.get_style_context() + color = style.get_color(Gtk.StateFlags.NORMAL) + value = (color.red + color.green + color.blue) / 3 + background = 'bright' if value >= 0.5 else 'dark' + log.debug('Menu bar color: (%.3g, %.3g, %.3g), averages to %.3g (%s)', + color.red, color.green, color.blue, value, background) + return background + class SimpleStatusIcon(IconChooser): """Status icon for gtimelog in the notification area.""" @@ -168,31 +125,17 @@ class SimpleStatusIcon(IconChooser): self.gtimelog_window = gtimelog_window self.timelog = gtimelog_window.timelog self.trayicon = None - if not hasattr(gtk, 'StatusIcon'): - # You must be using a very old PyGtk. - return - self.icon = gtk_status_icon_new(self.icon_name) + self.icon = Gtk.StatusIcon.new_from_file(self.icon_name) self.last_tick = False self.tick() self.icon.connect('activate', self.on_activate) self.icon.connect('popup-menu', self.on_popup_menu) - if gtk_version == 2: - self.gtimelog_window.main_window.connect( - 'style-set', self.on_style_set) - else: # assume Gtk+ 3 - self.gtimelog_window.main_window.connect( - 'style-updated', self.on_style_set) - gobject.timeout_add_seconds(1, self.tick) + self.gtimelog_window.main_window.connect( + 'style-updated', self.on_style_set) + GLib.timeout_add_seconds(1, self.tick) self.gtimelog_window.entry_watchers.append(self.entry_added) self.gtimelog_window.tray_icon = self - def available(self): - """Is the icon supported by this system? - - SimpleStatusIcon needs PyGtk 2.10 or newer - """ - return self.icon is not None - def on_style_set(self, *args): """The user chose a different theme.""" self.icon.set_from_file(self.icon_name) @@ -204,14 +147,9 @@ class SimpleStatusIcon(IconChooser): def on_popup_menu(self, widget, button, activate_time): """The user clicked on the icon.""" tray_icon_popup_menu = self.gtimelog_window.tray_icon_popup_menu - if toolkit == "gi": - tray_icon_popup_menu.popup( - None, None, gtk.StatusIcon.position_menu, - self.icon, button, activate_time) - else: - tray_icon_popup_menu.popup( - None, None, gtk.status_icon_position_menu, - button, activate_time, self.icon) + tray_icon_popup_menu.popup( + None, None, Gtk.StatusIcon.position_menu, + self.icon, button, activate_time) def entry_added(self, entry): """An entry has been added.""" @@ -244,153 +182,24 @@ class SimpleStatusIcon(IconChooser): class AppIndicator(IconChooser): """Ubuntu's application indicator for gtimelog.""" - # XXX: on Ubuntu 10.04 the app indicator apparently doesn't understand - # set_icon('/absolute/path'), and so gtimelog ends up being without an - # icon. I don't know if I want to continue supporting Ubuntu 10.04. - def __init__(self, gtimelog_window): self.gtimelog_window = gtimelog_window self.timelog = gtimelog_window.timelog self.indicator = None - if new_app_indicator is None: - return - self.indicator = new_app_indicator( - 'gtimelog', self.icon_name, APPINDICATOR_CATEGORY) - self.indicator.set_status(APPINDICATOR_ACTIVE) - self.indicator.set_menu(gtimelog_window.app_indicator_menu) - self.gtimelog_window.tray_icon = self - if gtk_version == 2: - self.gtimelog_window.main_window.connect( - 'style-set', self.on_style_set) - else: # assume Gtk+ 3 + if have_app_indicator: + self.indicator = AppIndicator3.Indicator.new( + 'gtimelog', self.icon_name, AppIndicator3.IndicatorCategory.APPLICATION_STATUS) + self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + self.indicator.set_menu(gtimelog_window.app_indicator_menu) + self.gtimelog_window.tray_icon = self self.gtimelog_window.main_window.connect( 'style-updated', self.on_style_set) - def available(self): - """Is the icon supported by this system? - - AppIndicator needs python-appindicator - """ - return self.indicator is not None - def on_style_set(self, *args): """The user chose a different theme.""" self.indicator.set_icon(self.icon_name) -class OldTrayIcon(IconChooser): - """Old tray icon for gtimelog, shows a ticking clock. - - Uses the old and deprecated egg.trayicon module. - """ - - def __init__(self, gtimelog_window): - self.gtimelog_window = gtimelog_window - self.timelog = gtimelog_window.timelog - self.trayicon = None - try: - import egg.trayicon - except ImportError: - # Nothing to do here, move along or install python-gnome2-extras - # which was later renamed to python-eggtrayicon. - return - self.eventbox = gtk.EventBox() - hbox = gtk.HBox() - self.icon = gtk.Image() - self.icon.set_from_file(self.icon_name) - hbox.add(self.icon) - self.time_label = gtk.Label() - hbox.add(self.time_label) - self.eventbox.add(hbox) - self.trayicon = egg.trayicon.TrayIcon('GTimeLog') - self.trayicon.add(self.eventbox) - self.last_tick = False - self.tick(force_update=True) - self.trayicon.show_all() - if gtk_version == 2: - self.gtimelog_window.main_window.connect( - 'style-set', self.on_style_set) - else: # assume Gtk+ 3 - self.gtimelog_window.main_window.connect( - 'style-updated', self.on_style_set) - tray_icon_popup_menu = gtimelog_window.tray_icon_popup_menu - self.eventbox.connect_object( - 'button-press-event', self.on_press, tray_icon_popup_menu) - self.eventbox.connect('button-release-event', self.on_release) - gobject.timeout_add_seconds(1, self.tick) - self.gtimelog_window.entry_watchers.append(self.entry_added) - self.gtimelog_window.tray_icon = self - - def available(self): - """Is the icon supported by this system? - - OldTrayIcon needs egg.trayicon, which is now deprecated and likely - no longer available in modern Linux distributions. - """ - return self.trayicon is not None - - def on_style_set(self, *args): - """The user chose a different theme.""" - self.icon.set_from_file(self.icon_name) - - def on_press(self, widget, event): - """A mouse button was pressed on the tray icon label.""" - if event.button != 3: - return - main_window = self.gtimelog_window.main_window - # This should be unnecessary, as we now show/hide menu items - # immediatelly after showing/hiding the main window. - if main_window.get_property('visible'): - self.gtimelog_window.tray_show.hide() - self.gtimelog_window.tray_hide.show() - else: - self.gtimelog_window.tray_show.show() - self.gtimelog_window.tray_hide.hide() - # I'm assuming toolkit == 'pygtk' here, since there's now way the old - # EggTrayIcon can work with PyGI/Gtk+ 3. - widget.popup(None, None, None, event.button, event.time) - - def on_release(self, widget, event): - """A mouse button was released on the tray icon label.""" - if event.button != 1: - return - self.gtimelog_window.toggle_visible() - - def entry_added(self, entry): - """An entry has been added.""" - self.tick(force_update=True) - - def tick(self, force_update=False): - """Tick every second.""" - now = datetime.datetime.now().replace(second=0, microsecond=0) - if now != self.last_tick or force_update: # Do not eat CPU too much - self.last_tick = now - last_time = self.timelog.window.last_time() - if last_time is None: - self.time_label.set_text(now.strftime('%H:%M')) - else: - self.time_label.set_text( - format_duration_short(now - last_time)) - self.trayicon.set_tooltip_text(self.tip()) - return True - - def tip(self): - """Compute tooltip text.""" - current_task = self.gtimelog_window.task_entry.get_text() - if not current_task: - current_task = 'nothing' - tip = 'GTimeLog: working on {0}'.format(current_task) - total_work, total_slacking = self.timelog.window.totals() - tip += '\nWork done today: {0}'.format(format_duration(total_work)) - time_left = self.gtimelog_window.time_left_at_work(total_work) - if time_left is not None: - if time_left < datetime.timedelta(0): - time_left = datetime.timedelta(0) - tip += '\nTime left at work: {0}'.format( - format_duration(time_left)) - return tip - - class MainWindow: """Main application window.""" @@ -420,7 +229,7 @@ class MainWindow: def _init_ui(self): """Initialize the user interface.""" - builder = gtk.Builder() + builder = Gtk.Builder() builder.add_from_file(ui_file) # Set initial state of menu items *before* we hook up signals chronological_menu_item = builder.get_object('chronological') @@ -465,9 +274,9 @@ class MainWindow: self.tasks.loaded_callback = self.task_list_loaded self.tasks.error_callback = self.task_list_error self.task_list = builder.get_object('task_list') - self.task_store = gtk.TreeStore(str, str) + self.task_store = Gtk.TreeStore(str, str) self.task_list.set_model(self.task_store) - column = gtk.TreeViewColumn('Task', gtk.CellRendererText(), text=0) + column = Gtk.TreeViewColumn('Task', Gtk.CellRendererText(), text=0) self.task_list.append_column(column) self.task_list.connect('row_activated', self.task_list_row_activated) self.task_list_popup_menu = builder.get_object('task_list_popup_menu') @@ -486,9 +295,9 @@ class MainWindow: self.add_button.connect('clicked', self.add_entry) buffer = self.log_view.get_buffer() self.log_buffer = buffer - buffer.create_tag('today', foreground='blue') - buffer.create_tag('duration', foreground='red') - buffer.create_tag('time', foreground='green') + buffer.create_tag('today', foreground='#204a87') # Tango dark blue + buffer.create_tag('duration', foreground='#ce5c00') # Tango dark orange + buffer.create_tag('time', foreground='#4e9a06') # Tango dark green buffer.create_tag('slacking', foreground='gray') self.set_up_task_list() self.set_up_completion() @@ -496,7 +305,10 @@ class MainWindow: self.populate_log() self.update_show_checkbox() self.tick(True) - gobject.timeout_add_seconds(1, self.tick) + GLib.timeout_add_seconds(1, self.tick) + + def quit(self): + self.main_window.destroy() def set_up_log_view_columns(self): """Set up tab stops in the log view.""" @@ -504,9 +316,9 @@ class MainWindow: self.log_view.realize() pango_context = self.log_view.get_pango_context() em = pango_context.get_font_description().get_size() - tabs = pango_tabarray_new(2, False) - tabs.set_tab(0, PANGO_ALIGN_LEFT, 9 * em) - tabs.set_tab(1, PANGO_ALIGN_LEFT, 12 * em) + tabs = Pango.TabArray.new(2, False) + tabs.set_tab(0, Pango.TabAlign.LEFT, 9 * em) + tabs.set_tab(1, Pango.TabAlign.LEFT, 12 * em) self.log_view.set_tabs(tabs) def w(self, text, tag=None): @@ -645,7 +457,7 @@ class MainWindow: def write_item(self, item): buffer = self.log_buffer - start, stop, duration, entry = item + start, stop, duration, tags, entry = item self.w(format_duration(duration), 'duration') period = '\t({0}-{1})\t'.format( start.strftime('%H:%M'), stop.strftime('%H:%M')) @@ -700,12 +512,12 @@ class MainWindow: if not self.settings.enable_gtk_completion: self.have_completion = False return - self.have_completion = hasattr(gtk, 'EntryCompletion') + self.have_completion = hasattr(Gtk, 'EntryCompletion') if not self.have_completion: return - self.completion_choices = gtk.ListStore(str) + self.completion_choices = Gtk.ListStore(str) self.completion_choices_as_set = set() - completion = gtk.EntryCompletion() + completion = Gtk.EntryCompletion() completion.set_model(self.completion_choices) completion.set_text_column(0) self.task_entry.set_completion(completion) @@ -737,7 +549,7 @@ class MainWindow: self.on_hide_activate() return True else: - gtk.main_quit() + self.quit() return False def close_about_dialog(self, widget): @@ -798,7 +610,7 @@ class MainWindow: def on_quit_activate(self, widget): """File -> Quit selected""" - gtk.main_quit() + self.quit() def on_about_activate(self, widget): """Help -> About selected""" @@ -855,7 +667,7 @@ class MainWindow: Returns either a datetime.date, or None. """ - if self.calendar_dialog.run() == GTK_RESPONSE_OK: + if self.calendar_dialog.run() == Gtk.ResponseType.OK: y, m1, d = self.calendar.get_date() day = datetime.date(y, m1 + 1, d) else: @@ -868,7 +680,7 @@ class MainWindow: Returns either a tuple with two datetime.date objects, or (None, None). """ - if self.two_calendar_dialog.run() == GTK_RESPONSE_OK: + if self.two_calendar_dialog.run() == Gtk.ResponseType.OK: y1, m1, d1 = self.calendar1.get_date() y2, m2, d2 = self.calendar2.get_date() first = datetime.date(y1, m1 + 1, d1) @@ -880,7 +692,7 @@ class MainWindow: def on_calendar_day_selected_double_click(self, widget): """Double-click on a calendar day: close the dialog.""" - self.calendar_dialog.response(GTK_RESPONSE_OK) + self.calendar_dialog.response(Gtk.ResponseType.OK) def weekly_window(self, day=None): if not day: @@ -983,14 +795,14 @@ class MainWindow: def on_open_complete_spreadsheet_activate(self, widget): """Report -> Complete Report in Spreadsheet""" - tempfn = tempfile.mktemp(suffix='gtimelog.csv') # XXX unsafe! + tempfn = tempfile.mktemp(prefix='gtimelog-', suffix='.csv') # XXX unsafe! with open(tempfn, 'w') as f: self.timelog.whole_history().to_csv_complete(f) self.spawn(self.settings.spreadsheet, tempfn) def on_open_slack_spreadsheet_activate(self, widget): """Report -> Work/_Slacking stats in Spreadsheet""" - tempfn = tempfile.mktemp(suffix='gtimelog.csv') # XXX unsafe! + tempfn = tempfile.mktemp(prefix='gtimelog-', suffix='.csv') # XXX unsafe! with open(tempfn, 'w') as f: self.timelog.whole_history().to_csv_daily(f) self.spawn(self.settings.spreadsheet, tempfn) @@ -1001,7 +813,7 @@ class MainWindow: def mail(self, write_draft): """Send an email.""" - draftfn = tempfile.mktemp(suffix='gtimelog') # XXX unsafe! + draftfn = tempfile.mktemp(prefix='gtimelog-') # XXX unsafe! with codecs.open(draftfn, 'w', encoding='UTF-8') as draft: write_draft(draft, self.settings.email, self.settings.name) self.spawn(self.settings.mailer, draftfn) @@ -1042,6 +854,7 @@ class MainWindow: model = treeview.get_model() task = model[path][1] self.task_entry.set_text(task) + def grab_focus(): self.task_entry.grab_focus() self.task_entry.set_position(-1) @@ -1049,14 +862,11 @@ class MainWindow: # handled _after_ row-activated, which makes the tree control steal # the focus back from the task entry. To avoid this, wait until all # the events have been handled. - gobject.idle_add(grab_focus) + GObject.idle_add(grab_focus) def task_list_button_press(self, menu, event): if event.button == 3: - if toolkit == "gi": - menu.popup(None, None, None, None, event.button, event.time) - else: - menu.popup(None, None, None, event.button, event.time) + menu.popup(None, None, None, None, event.button, event.time) return True else: return False @@ -1073,8 +883,8 @@ class MainWindow: self.task_pane_info_label.set_text('Loading...') self.task_pane_info_label.show() # let the ui update become visible - while gtk.events_pending(): - gtk.main_iteration() + while Gtk.events_pending(): + Gtk.main_iteration() def task_list_error(self): self.task_list_loading_failed = True @@ -1091,23 +901,23 @@ class MainWindow: def task_entry_key_press(self, widget, event): """Handle key presses in task entry.""" - if event.keyval == gdk.keyval_from_name('Escape') and self.tray_icon: + if event.keyval == Gdk.keyval_from_name('Escape') and self.tray_icon: self.on_hide_activate() return True - if event.keyval == gdk.keyval_from_name('Prior'): + if event.keyval == Gdk.keyval_from_name('Prior'): self._do_history(1) return True - if event.keyval == gdk.keyval_from_name('Next'): + if event.keyval == Gdk.keyval_from_name('Next'): self._do_history(-1) return True # XXX This interferes with the completion box. How do I determine # whether the completion box is visible or not? if self.have_completion: return False - if event.keyval == gdk.keyval_from_name('Up'): + if event.keyval == Gdk.keyval_from_name('Up'): self._do_history(1) return True - if event.keyval == gdk.keyval_from_name('Down'): + if event.keyval == Gdk.keyval_from_name('Down'): self._do_history(-1) return True return False @@ -1144,29 +954,7 @@ class MainWindow: self.jump_to_today() entry = self._get_entry_text() - - now = None - date_match = re.match(r'(\d\d):(\d\d)\s+', entry) - delta_match = re.match(r'-([1-9]\d?|1\d\d)\s+', entry) - if date_match: - h = int(date_match.group(1)) - m = int(date_match.group(2)) - if 0 <= h < 24 and 0 <= m <= 60: - now = datetime.datetime.now() - now = now.replace(hour=h, minute=m, second=0, microsecond=0) - if self.timelog.valid_time(now): - entry = entry[date_match.end():] - else: - now = None - if delta_match: - seconds = int(delta_match.group()) * 60 - now = datetime.datetime.now().replace(second=0, microsecond=0) - now += datetime.timedelta(seconds=seconds) - if self.timelog.valid_time(now): - entry = entry[delta_match.end():] - else: - now = None - + entry, now = self.timelog.parse_correction(entry) if not entry: return self.add_history(entry) @@ -1213,215 +1001,173 @@ class MainWindow: return True -if dbus: - INTERFACE = 'lt.pov.mg.gtimelog.Service' - OBJECT_PATH = '/lt/pov/mg/gtimelog/Service' - SERVICE = 'lt.pov.mg.gtimelog.GTimeLog' - - class Service(dbus.service.Object): - """Our DBus service, used to communicate with the main instance.""" - - def __init__(self, main_window): - session_bus = dbus.SessionBus() - connection = dbus.service.BusName(SERVICE, session_bus) - dbus.service.Object.__init__(self, connection, OBJECT_PATH) +def make_option(long_name, short_name=None, flags=0, arg=GLib.OptionArg.NONE, + arg_data=None, description=None, arg_description=None): + # surely something like this should exist inside PyGObject itself?! + option = GLib.OptionEntry() + option.long_name = long_name.lstrip('-') + option.short_name = 0 if not short_name else short_name.lstrip('-') + option.flags = flags + option.arg = arg + option.arg_data = arg_data + option.description = description + option.arg_description = arg_description + return option + + +class Application(Gtk.Application): + def __init__(self, *args, **kwargs): + kwargs['application_id'] = 'lt.pov.mg.gtimelog' + kwargs['flags'] = Gio.ApplicationFlags.HANDLES_COMMAND_LINE + Gtk.Application.__init__(self, *args, **kwargs) + self.add_main_option_entries([ + make_option("--version", description="Show version number and exit"), + make_option("--tray", description="Start minimized"), + make_option("--toggle", description="Show/hide the GTimeLog window if already running"), + make_option("--quit", description="Tell an already-running GTimeLog instance to quit"), + make_option("--sample-config", description="Write a sample configuration file to 'gtimelogrc.sample'"), + make_option("--debug", description="Show debug information"), + ]) + self.main_window = None + self.debug = False + self.start_minimized = False + + def do_handle_local_options(self, options): + if options.contains('version'): + print(gtimelog.__version__) + return 0 + if options.contains('sample-config'): + settings = Settings() + settings.save("gtimelogrc.sample") + print("Sample configuration file written to gtimelogrc.sample") + print("Edit it and save as %s" % settings.get_config_file()) + return 0 + self.debug = options.contains('debug') + self.start_minimized = options.contains('tray') + if options.contains('quit'): + print('gtimelog: Telling the already-running instance to quit') + return -1 # send the args to the remote instance for processing + + def do_command_line(self, command_line): + options = command_line.get_options_dict() + if options.contains('toggle') and self.main_window is not None: + # NB: Even if there's no tray icon, it's still possible to + # hide the gtimelog window. Bug or feature? + self.main_window.toggle_visible() + return 0 + if options.contains('quit'): + if self.main_window: + self.main_window.quit() + else: + print('gtimelog: not running') + return 0 - self.main_window = main_window + self.do_activate() + return 0 - @dbus.service.method(INTERFACE) - def ToggleFocus(self): - self.main_window.toggle_visible() + def do_activate(self): + if self.main_window is not None: + self.main_window.main_window.present() + return - @dbus.service.method(INTERFACE) - def Present(self): - self.main_window.on_show_activate() + debug = self.debug + start_minimized = self.start_minimized - @dbus.service.method(INTERFACE) - def Quit(self): - gtk.main_quit() + log.addHandler(logging.StreamHandler(sys.stdout)) + if debug: + log.setLevel(logging.DEBUG) + else: + log.setLevel(logging.INFO) + if debug: + print('GTimeLog version: %s' % gtimelog.__version__) + print('Python version: %s' % sys.version) + print('Gtk+ version: %s.%s.%s' % (Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION, Gtk.MICRO_VERSION)) + print('Config directory: %s' % Settings().get_config_dir()) + print('Data directory: %s' % Settings().get_data_dir()) -def main(): - """Run the program.""" - parser = optparse.OptionParser(usage='%prog [options]', - version=gtimelog.__version__) - parser.add_option('--tray', action='store_true', - help="start minimized") - parser.add_option('--sample-config', action='store_true', - help="write a sample configuration file to 'gtimelogrc.sample'") - - dbus_options = optparse.OptionGroup(parser, "Single-Instance Options") - dbus_options.add_option('--replace', action='store_true', - help="replace the already running GTimeLog instance") - dbus_options.add_option('--quit', action='store_true', - help="tell an already-running GTimeLog instance to quit") - dbus_options.add_option('--toggle', action='store_true', - help="show/hide the GTimeLog window if already running") - dbus_options.add_option('--ignore-dbus', action='store_true', - help="do not check if GTimeLog is already running" - " (allows you to have multiple instances running)") - parser.add_option_group(dbus_options) - - debug_options = optparse.OptionGroup(parser, "Debugging Options") - debug_options.add_option('--debug', action='store_true', - help="show debug information") - debug_options.add_option('--prefer-pygtk', action='store_true', - help="try to use the (obsolete) pygtk library instead of pygi") - parser.add_option_group(debug_options) - - opts, args = parser.parse_args() - - log.addHandler(logging.StreamHandler(sys.stdout)) - if opts.debug: - log.setLevel(logging.DEBUG) - else: - log.setLevel(logging.INFO) - - if opts.sample_config: settings = Settings() - settings.save("gtimelogrc.sample") - print("Sample configuration file written to gtimelogrc.sample") - print("Edit it and save as %s" % settings.get_config_file()) - return - - global dbus - - if opts.debug: - print('GTimeLog version: %s' % gtimelog.__version__) - print('Python version: %s' % sys.version) - print('Toolkit: %s' % toolkit) - print('Gtk+ version: %s' % gtk_version) - print('D-Bus available: %s' % ('yes' if dbus else 'no')) - print('Config directory: %s' % Settings().get_config_dir()) - print('Data directory: %s' % Settings().get_data_dir()) - - if opts.ignore_dbus: - dbus = None - - # Let's check if there is already an instance of GTimeLog running - # and if it is make it present itself or when it is already presented - # hide it and then quit. - if dbus: - dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) - + configdir = settings.get_config_dir() + datadir = settings.get_data_dir() try: - session_bus = dbus.SessionBus() - dbus_service = session_bus.get_object(SERVICE, OBJECT_PATH) - if opts.replace or opts.quit: - print('gtimelog: Telling the already-running instance to quit') - dbus_service.Quit() - if opts.quit: - sys.exit() - elif opts.toggle: - dbus_service.ToggleFocus() - print('gtimelog: Already running, toggling visibility') - sys.exit() - elif opts.tray: - print('gtimelog: Already running, not doing anything') - sys.exit() - else: - dbus_service.Present() - print('gtimelog: Already running, presenting main window') - sys.exit() - except dbus.DBusException as e: - if e.get_dbus_name() == 'org.freedesktop.DBus.Error.ServiceUnknown': - # gtimelog is not running: that's fine and not an error at all - if opts.quit: - print('gtimelog is not running') - sys.exit() - elif opts.quit or opts.replace or opts.toggle: - # we need dbus to work for this, so abort - sys.exit('gtimelog: %s' % e) - else: - # otherwise just emit a warning - print("gtimelog: dbus is not available:\n %s" % e) - else: # not dbus - if opts.quit or opts.replace or opts.toggle: - sys.exit("gtimelog: dbus not available") - - settings = Settings() - configdir = settings.get_config_dir() - datadir = settings.get_data_dir() - try: - # Create configdir if it doesn't exist. - os.makedirs(configdir) - except OSError as error: - if error.errno != errno.EEXIST: - # XXX: not the most friendly way of error reporting for a GUI app - raise - try: - # Create datadir if it doesn't exist. - os.makedirs(datadir) - except OSError as error: - if error.errno != errno.EEXIST: - raise - - settings_file = settings.get_config_file() - if not os.path.exists(settings_file): - if opts.debug: - print('Saving settings to %s' % settings_file) - settings.save(settings_file) - else: - if opts.debug: - print('Loading settings from %s' % settings_file) - settings.load(settings_file) - if opts.debug: - print('Assuming date changes at %s' % settings.virtual_midnight) - print('Loading time log from %s' % settings.get_timelog_file()) - timelog = TimeLog(settings.get_timelog_file(), - settings.virtual_midnight) - if settings.task_list_url: - if opts.debug: - print('Loading cached remote tasks from %s' % - os.path.join(datadir, 'remote-tasks.txt')) - tasks = RemoteTaskList(settings.task_list_url, - os.path.join(datadir, 'remote-tasks.txt')) - else: - if opts.debug: - print('Loading tasks from %s' % os.path.join(datadir, 'tasks.txt')) - tasks = TaskList(os.path.join(datadir, 'tasks.txt')) - main_window = MainWindow(timelog, settings, tasks) - start_in_tray = False - if settings.show_tray_icon: - if settings.prefer_app_indicator: - icons = [AppIndicator, SimpleStatusIcon, OldTrayIcon] - elif settings.prefer_old_tray_icon: - icons = [OldTrayIcon, SimpleStatusIcon, AppIndicator] + # Create configdir if it doesn't exist. + os.makedirs(configdir) + except OSError as error: + if error.errno != errno.EEXIST: + # XXX: not the most friendly way of error reporting for a GUI app + raise + try: + # Create datadir if it doesn't exist. + os.makedirs(datadir) + except OSError as error: + if error.errno != errno.EEXIST: + raise + + settings_file = settings.get_config_file() + if not os.path.exists(settings_file): + if debug: + print('Saving settings to %s' % settings_file) + settings.save(settings_file) else: - icons = [SimpleStatusIcon, OldTrayIcon, AppIndicator] - if opts.debug: - print('Tray icon preference: %s' % ', '.join(icon_class.__name__ - for icon_class in icons)) - for icon_class in icons: - tray_icon = icon_class(main_window) - if tray_icon.available(): - if opts.debug: - print('Tray icon: %s' % icon_class.__name__) + if debug: + print('Loading settings from %s' % settings_file) + settings.load(settings_file) + if debug: + print('Assuming date changes at %s' % settings.virtual_midnight) + print('Loading time log from %s' % settings.get_timelog_file()) + timelog = TimeLog(settings.get_timelog_file(), + settings.virtual_midnight) + if settings.task_list_url: + if debug: + print('Loading cached remote tasks from %s' % + os.path.join(datadir, 'remote-tasks.txt')) + tasks = RemoteTaskList(settings.task_list_url, + os.path.join(datadir, 'remote-tasks.txt')) + else: + if debug: + print('Loading tasks from %s' % os.path.join(datadir, 'tasks.txt')) + tasks = TaskList(os.path.join(datadir, 'tasks.txt')) + self.main_window = MainWindow(timelog, settings, tasks) + self.add_window(self.main_window.main_window) + start_in_tray = False + + if settings.show_tray_icon: + if debug: + print('Tray icon preference: %s' % ('AppIndicator' + if settings.prefer_app_indicator + else 'SimpleStatusIcon')) + + if settings.prefer_app_indicator and have_app_indicator: + tray_icon = AppIndicator(self.main_window) + else: + tray_icon = SimpleStatusIcon(self.main_window) + + if tray_icon: + if debug: + print('Using: %s' % tray_icon.__class__.__name__) + start_in_tray = (settings.start_in_tray if settings.start_in_tray - else opts.tray) - break # found one that works - else: - if opts.debug: - print('%s not available' % icon_class.__name__) - if not start_in_tray: - main_window.on_show_activate() - else: - if opts.debug: - print('Starting minimized') - if dbus: - try: - service = Service(main_window) # noqa - except dbus.DBusException as e: - print("gtimelog: dbus is not available:\n %s" % e) - # This is needed to make ^C terminate gtimelog when we're using - # gobject-introspection. - signal.signal(signal.SIGINT, signal.SIG_DFL) - try: - gtk.main() - except KeyboardInterrupt: - pass + else start_minimized) + + if debug: + print('GTK+ completion: %s' % ('enabled' if settings.enable_gtk_completion else 'disabled')) + if not start_in_tray: + self.main_window.on_show_activate() + else: + if debug: + print('Starting minimized') + + # This is needed to make ^C terminate gtimelog when we're using + # gobject-introspection. + signal.signal(signal.SIGINT, signal.SIG_DFL) + + +def main(): + """Run the program.""" + app = Application() + app.run(sys.argv) if __name__ == '__main__': main() diff --git a/src/gtimelog/settings.py b/src/gtimelog/settings.py index baed32c..fb68277 100644 --- a/src/gtimelog/settings.py +++ b/src/gtimelog/settings.py @@ -48,9 +48,8 @@ class Settings(object): edit_task_list_cmd = '' show_office_hours = True - show_tray_icon = True + show_tray_icon = False prefer_app_indicator = True - prefer_old_tray_icon = False start_in_tray = False report_style = 'plain' @@ -108,8 +107,6 @@ class Settings(object): config.set('gtimelog', 'show_tray_icon', str(self.show_tray_icon)) config.set('gtimelog', 'prefer_app_indicator', str(self.prefer_app_indicator)) - config.set('gtimelog', 'prefer_old_tray_icon', - str(self.prefer_old_tray_icon)) config.set('gtimelog', 'report_style', str(self.report_style)) config.set('gtimelog', 'start_in_tray', str(self.start_in_tray)) return config @@ -144,8 +141,6 @@ class Settings(object): self.show_tray_icon = config.getboolean('gtimelog', 'show_tray_icon') self.prefer_app_indicator = config.getboolean('gtimelog', 'prefer_app_indicator') - self.prefer_old_tray_icon = config.getboolean('gtimelog', - 'prefer_old_tray_icon') self.report_style = config.get('gtimelog', 'report_style') self.start_in_tray = config.getboolean('gtimelog', 'start_in_tray') diff --git a/src/gtimelog/tests.py b/src/gtimelog/tests.py index 34aa50b..8e60ff4 100644 --- a/src/gtimelog/tests.py +++ b/src/gtimelog/tests.py @@ -1,17 +1,24 @@ """Tests for gtimelog""" +import datetime import doctest -import unittest import os -import tempfile import shutil +import tempfile +import textwrap +import unittest +import sys from pprint import pprint - try: from cStringIO import StringIO except ImportError: from io import StringIO +import freezegun +import mock + +from gtimelog.timelog import TimeLog + def doctest_as_hours(): """Tests for as_hours @@ -248,6 +255,22 @@ def doctest_uniq(): """ +def doctest_TimeWindow_repr(): + """Test for TimeWindow.__repr__ + + >>> from datetime import datetime, time + >>> min = datetime(2013, 12, 3) + >>> max = datetime(2013, 12, 4) + >>> vm = time(2, 0) + + >>> from gtimelog.timelog import TimeWindow + >>> window = TimeWindow('/nosuchfile', min, max, vm) + >>> window + <TimeWindow: 2013-12-03 00:00:00..2013-12-04 00:00:00> + + """ + + def doctest_TimeWindow_reread_no_file(): """Test for TimeWindow.reread @@ -404,7 +427,7 @@ def doctest_TimeWindow_last_entry(): >>> window.items = [ ... (datetime(2013, 12, 4, 9, 0), 'started **'), ... ] - >>> start, stop, duration, entry = window.last_entry() + >>> start, stop, duration, tags, entry = window.last_entry() >>> start == stop == datetime(2013, 12, 4, 9, 0) True >>> duration @@ -418,7 +441,7 @@ def doctest_TimeWindow_last_entry(): ... (datetime(2013, 12, 3, 12, 0), 'stuff'), ... (datetime(2013, 12, 4, 9, 0), 'started **'), ... ] - >>> start, stop, duration, entry = window.last_entry() + >>> start, stop, duration, tags, entry = window.last_entry() >>> start == stop == datetime(2013, 12, 4, 9, 0) True >>> duration @@ -433,7 +456,7 @@ def doctest_TimeWindow_last_entry(): ... (datetime(2013, 12, 4, 9, 0), 'started **'), ... (datetime(2013, 12, 4, 9, 31), 'gtimelog: tests'), ... ] - >>> start, stop, duration, entry = window.last_entry() + >>> start, stop, duration, tags, entry = window.last_entry() >>> start datetime.datetime(2013, 12, 4, 9, 0) >>> stop @@ -468,7 +491,6 @@ def doctest_TimeWindow_to_csv_complete(): >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(sampledata, min, max, vm) - >>> import sys >>> window.to_csv_complete(sys.stdout) task,time (minutes) etc,60 @@ -499,7 +521,6 @@ def doctest_TimeWindow_to_csv_daily(): >>> from gtimelog.timelog import TimeWindow >>> window = TimeWindow(sampledata, min, max, vm) - >>> import sys >>> window.to_csv_daily(sys.stdout) date,day-start (hours),slacking (hours),work (hours) 2008-06-03,12.75,0.0,3.0 @@ -509,11 +530,84 @@ def doctest_TimeWindow_to_csv_daily(): """ +def doctest_TimeWindow_icalendar(): + r"""Tests for TimeWindow.icalendar + + >>> from datetime import datetime, time + >>> min = datetime(2008, 6, 1) + >>> max = datetime(2008, 7, 1) + >>> vm = time(2, 0) + + >>> sampledata = StringIO(r''' + ... 2008-06-03 12:45: start ** + ... 2008-06-03 13:00: something + ... 2008-06-03 15:45: something, else; with special\chars + ... 2008-06-05 12:45: start ** + ... 2008-06-05 13:15: something + ... 2008-06-05 14:15: rest ** + ... ''') + + >>> from gtimelog.timelog import TimeWindow + >>> window = TimeWindow(sampledata, min, max, vm) + + >>> with freezegun.freeze_time("2015-05-18 15:40"): + ... with mock.patch('socket.getfqdn') as mock_getfqdn: + ... mock_getfqdn.return_value = 'localhost' + ... window.icalendar(sys.stdout) + ... # doctest: +REPORT_NDIFF + BEGIN:VCALENDAR + PRODID:-//mg.pov.lt/NONSGML GTimeLog//EN + VERSION:2.0 + BEGIN:VEVENT + UID:be5f9be205c2308f7f1a30d6c399d6bd@localhost + SUMMARY:start ** + DTSTART:20080603T124500 + DTEND:20080603T124500 + DTSTAMP:20150518T154000Z + END:VEVENT + BEGIN:VEVENT + UID:33c7e212fed11eda71d5acd4bd22119b@localhost + SUMMARY:something + DTSTART:20080603T124500 + DTEND:20080603T130000 + DTSTAMP:20150518T154000Z + END:VEVENT + BEGIN:VEVENT + UID:b10c11beaf91df16964a46b4c87420b1@localhost + SUMMARY:something\, else\; with special\\chars + DTSTART:20080603T130000 + DTEND:20080603T154500 + DTSTAMP:20150518T154000Z + END:VEVENT + BEGIN:VEVENT + UID:04964eef67ec22178d74fe4c0f06aa2a@localhost + SUMMARY:start ** + DTSTART:20080605T124500 + DTEND:20080605T124500 + DTSTAMP:20150518T154000Z + END:VEVENT + BEGIN:VEVENT + UID:2b51ea6d1c26f02d58051a691657068d@localhost + SUMMARY:something + DTSTART:20080605T124500 + DTEND:20080605T131500 + DTSTAMP:20150518T154000Z + END:VEVENT + BEGIN:VEVENT + UID:bd6bfd401333dbbf34fec941567d5d06@localhost + SUMMARY:rest ** + DTSTART:20080605T131500 + DTEND:20080605T141500 + DTSTAMP:20150518T154000Z + END:VEVENT + END:VCALENDAR + + """ + + def doctest_Reports_weekly_report_categorized(): r"""Tests for Reports.weekly_report_categorized - >>> import sys - >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports @@ -577,8 +671,6 @@ def doctest_Reports_weekly_report_categorized(): def doctest_Reports_monthly_report_categorized(): r"""Tests for Reports.monthly_report_categorized - >>> import sys - >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports @@ -639,8 +731,6 @@ def doctest_Reports_monthly_report_categorized(): def doctest_Reports_report_categories(): r"""Tests for Reports._report_categories - >>> import sys - >>> from datetime import datetime, time, timedelta >>> from gtimelog.timelog import TimeWindow, Reports @@ -668,8 +758,6 @@ def doctest_Reports_report_categories(): def doctest_Reports_daily_report(): r"""Tests for Reports.daily_report - >>> import sys - >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports @@ -725,8 +813,6 @@ def doctest_Reports_daily_report(): def doctest_Reports_weekly_report_plain(): r"""Tests for Reports.weekly_report_plain - >>> import sys - >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports @@ -776,8 +862,6 @@ def doctest_Reports_weekly_report_plain(): def doctest_Reports_monthly_report_plain(): r"""Tests for Reports.monthly_report_plain - >>> import sys - >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports @@ -827,8 +911,6 @@ def doctest_Reports_monthly_report_plain(): def doctest_Reports_custom_range_report_categorized(): r"""Tests for Reports.custom_range_report_categorized - >>> import sys - >>> from datetime import datetime, time >>> from gtimelog.timelog import TimeWindow, Reports @@ -944,6 +1026,123 @@ def doctest_TaskList_real_file(): """ +class TestTimeLog(unittest.TestCase): + + def setUp(self): + self.tempdir = None + + def tearDown(self): + if self.tempdir: + shutil.rmtree(self.tempdir) + + def mkdtemp(self): + if self.tempdir is None: + self.tempdir = tempfile.mkdtemp(prefix='gtimelog-test-') + return self.tempdir + + def test_appending_clears_window_cache(self): + # Regression test for https://github.com/gtimelog/gtimelog/issues/28 + tempfile = os.path.join(self.mkdtemp(), 'timelog.txt') + timelog = TimeLog(tempfile, datetime.time(2, 0)) + + w = timelog.window_for_day(datetime.date(2014, 11, 12)) + self.assertEqual(list(w.all_entries()), []) + + timelog.append('started **', now=datetime.datetime(2014, 11, 12, 10, 00)) + w = timelog.window_for_day(datetime.date(2014, 11, 12)) + self.assertEqual(len(list(w.all_entries())), 1) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_valid_time_accepts_any_time_in_the_past_when_log_is_empty(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + past = datetime.datetime(2015, 5, 12, 14, 20) + self.assertTrue(timelog.valid_time(past)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_valid_time_rejects_times_in_the_future(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + future = datetime.datetime(2015, 5, 12, 16, 30) + self.assertFalse(timelog.valid_time(future)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_valid_time_rejects_times_before_last_entry(self): + timelog = TimeLog(StringIO("2015-05-12 15:00: did stuff"), + datetime.time(2, 0)) + past = datetime.datetime(2015, 5, 12, 14, 20) + self.assertFalse(timelog.valid_time(past)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_valid_time_accepts_times_between_last_entry_and_now(self): + timelog = TimeLog(StringIO("2015-05-12 15:00: did stuff"), + datetime.time(2, 0)) + past = datetime.datetime(2015, 5, 12, 15, 20) + self.assertTrue(timelog.valid_time(past)) + + def test_parse_correction_leaves_regular_text_alone(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("did stuff"), + ("did stuff", None)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_parse_correction_recognizes_absolute_times(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("15:20 did stuff"), + ("did stuff", datetime.datetime(2015, 5, 12, 15, 20))) + + @freezegun.freeze_time("2015-05-13 00:27") + def test_parse_correction_handles_virtual_midnight_yesterdays_time(self): + # Regression test for https://github.com/gtimelog/gtimelog/issues/33 + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("15:20 did stuff"), + ("did stuff", datetime.datetime(2015, 5, 12, 15, 20))) + + @freezegun.freeze_time("2015-05-13 00:27") + def test_parse_correction_handles_virtual_midnight_todays_time(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("00:15 did stuff"), + ("did stuff", datetime.datetime(2015, 5, 13, 00, 15))) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_parse_correction_ignores_future_absolute_times(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("17:20 did stuff"), + ("17:20 did stuff", None)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_parse_correction_ignores_bad_absolute_times(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("19:60 did stuff"), + ("19:60 did stuff", None)) + self.assertEqual(timelog.parse_correction("24:00 did stuff"), + ("24:00 did stuff", None)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_parse_correction_ignores_absolute_times_before_last_entry(self): + timelog = TimeLog(StringIO("2015-05-12 16:00: stuff"), + datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("15:20 did stuff"), + ("15:20 did stuff", None)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_parse_correction_recognizes_relative_times(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("-20 did stuff"), + ("did stuff", datetime.datetime(2015, 5, 12, 16, 7))) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_parse_correction_ignores_relative_times_before_last_entry(self): + timelog = TimeLog(StringIO("2015-05-12 16:00: stuff"), + datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("-30 did stuff"), + ("-30 did stuff", None)) + + @freezegun.freeze_time("2015-05-12 16:27") + def test_parse_correction_ignores_bad_relative_times(self): + timelog = TimeLog(StringIO(), datetime.time(2, 0)) + self.assertEqual(timelog.parse_correction("-200 did stuff"), + ("-200 did stuff", None)) + + class TestSettings(unittest.TestCase): def setUp(self): @@ -1046,6 +1245,113 @@ class TestSettings(unittest.TestCase): self.settings.save(os.path.join(tempdir, 'config')) +class TestTagging (unittest.TestCase): + + TEST_TIMELOG = textwrap.dedent(""" + 2014-05-27 10:03: arrived + 2014-05-27 10:13: edx: introduce topic to new sysadmins -- edx + 2014-05-27 10:30: email + 2014-05-27 12:11: meeting: how to support new courses? -- edx meeting + 2014-05-27 15:12: edx: write test procedure for EdX instances -- edx sysadmin + 2014-05-27 17:03: cluster: set-up accounts, etc. -- sysadmin hpc + 2014-05-27 17:14: support: how to run statistics on Hydra? -- support hydra + 2014-05-27 17:36: off: pause ** + 2014-05-27 17:38: email + 2014-05-27 19:06: off: dinner & family ** + 2014-05-27 22:19: cluster: fix shmmax-shmall issue -- sysadmin hpc + """) + + def setUp(self): + from gtimelog.timelog import TimeWindow + self.tw = TimeWindow( + filename=StringIO(self.TEST_TIMELOG), + min_timestamp=datetime.datetime(2014, 5, 27, 9, 0), + max_timestamp=datetime.datetime(2014, 5, 27, 23, 59), + virtual_midnight=datetime.time(2, 0)) + + def test_TimeWindow_set_of_all_tags(self): + tags = self.tw.set_of_all_tags() + self.assertEqual(tags, + set(['edx', 'hpc', 'hydra', + 'meeting', 'support', 'sysadmin'])) + + def test_TimeWindow_totals_per_tag1(self): + """Test aggregate time per tag, 1 entry only""" + result = self.tw.totals('meeting') + self.assertEqual(len(result), 2) + work, slack = result + self.assertEqual(work, ( + # start/end times are manually extracted from the TEST_TIMELOG sample + (datetime.timedelta(hours=12, minutes=11) - datetime.timedelta(hours=10, minutes=30)) + )) + self.assertEqual(slack, datetime.timedelta(0)) + + def test_TimeWindow_totals_per_tag2(self): + """Test aggregate time per tag, several entries""" + result = self.tw.totals('hpc') + self.assertEqual(len(result), 2) + work, slack = result + self.assertEqual(work, ( + # start/end times are manually extracted from the TEST_TIMELOG sample + (datetime.timedelta(hours=17, minutes=3) - datetime.timedelta(hours=15, minutes=12)) + + (datetime.timedelta(hours=22, minutes=19) - datetime.timedelta(hours=19, minutes=6)) + )) + self.assertEqual(slack, datetime.timedelta(0)) + + def test_TimeWindow__split_entry_and_tags1(self): + """Test `TimeWindow._split_entry_and_tags` with simple entry""" + result = self.tw._split_entry_and_tags('email') + self.assertEqual(len(result), 2) + self.assertEqual(result[0], 'email') + self.assertEqual(result[1], set()) + + def test_TimeWindow__split_entry_and_tags2(self): + """Test `TimeWindow._split_entry_and_tags` with simple entry and tags""" + result = self.tw._split_entry_and_tags('restart CFEngine server -- sysadmin cfengine issue327') + self.assertEqual(len(result), 2) + self.assertEqual(result[0], 'restart CFEngine server') + self.assertEqual(result[1], set(['sysadmin', 'cfengine', 'issue327'])) + + def test_TimeWindow__split_entry_and_tags3(self): + """Test `TimeWindow._split_entry_and_tags` with category, entry, and tags""" + result = self.tw._split_entry_and_tags('tooling: tagging support in gtimelog -- tooling gtimelog') + self.assertEqual(len(result), 2) + self.assertEqual(result[0], 'tooling: tagging support in gtimelog') + self.assertEqual(result[1], set(['tooling', 'gtimelog'])) + + def test_TimeWindow__split_entry_and_tags4(self): + """Test `TimeWindow._split_entry_and_tags` with slack-type entry""" + result = self.tw._split_entry_and_tags('read news -- reading **') + self.assertEqual(len(result), 2) + self.assertEqual(result[0], 'read news **') + self.assertEqual(result[1], set(['reading'])) + + def test_TimeWindow__split_entry_and_tags5(self): + """Test `TimeWindow._split_entry_and_tags` with slack-type entry""" + result = self.tw._split_entry_and_tags('read news -- reading ***') + self.assertEqual(len(result), 2) + self.assertEqual(result[0], 'read news ***') + self.assertEqual(result[1], set(['reading'])) + + def test_Reports__report_tags(self): + from gtimelog.timelog import Reports + rp = Reports(self.tw) + txt = StringIO() + # use same tags as in tests above, so we know the totals + rp._report_tags(txt, ['meeting', 'hpc']) + self.assertEqual( + txt.getvalue().strip(), + textwrap.dedent(""" + Time spent in each area: + + hpc 5:04 + meeting 1:41 + + Note that area totals may not add up to the period totals, + as each entry may be belong to multiple areas (or none at all). + """).strip()) + + def additional_tests(): # for setup.py return doctest.DocTestSuite(optionflags=doctest.NORMALIZE_WHITESPACE) @@ -1058,7 +1364,7 @@ def test_suite(): def main(): - unittest.TextTestRunner().run(test_suite()) + unittest.main(module='gtimelog.tests', defaultTest='test_suite') if __name__ == '__main__': diff --git a/src/gtimelog/timelog.py b/src/gtimelog/timelog.py index b6a12d8..cc1ad11 100644 --- a/src/gtimelog/timelog.py +++ b/src/gtimelog/timelog.py @@ -6,9 +6,11 @@ import codecs import csv import datetime import os +import socket import sys import re import urllib +from hashlib import md5 from operator import itemgetter @@ -138,6 +140,10 @@ class TimeWindow(object): self.virtual_midnight = virtual_midnight self.reread(callback) + def __repr__(self): + return '<TimeWindow: {}..{}>'.format(self.min_timestamp, + self.max_timestamp) + def reread(self, callback=None): """Parse the time log file and update self.items. @@ -189,10 +195,37 @@ class TimeWindow(object): return None return self.items[-1][0] + @staticmethod + def _split_entry_and_tags(entry): + """ + Split the entry title (proper) from the trailing tags. + + Tags are separated from the title by a `` -- `` marker: + anything *before* the marker is the entry title, + anything *following* it is the (space-separated) set of tags. + + Return a tuple consisting of entry title and set of tags. + """ + if ' -- ' in entry: + entry, tags_bundle = entry.split(' -- ', 1) + # there might be spaces preceding ' -- ' + entry = entry.rstrip() + tags = set(tags_bundle.split()) + # put back '**' and '***' if they were in the tags part + if '***' in tags: + entry += ' ***' + tags.remove('***') + elif '**' in tags: + entry += ' **' + tags.remove('**') + else: + tags = set() + return entry, tags + def all_entries(self): """Iterate over all entries. - Yields (start, stop, duration, entry) tuples. The first entry + Yields (start, stop, duration, tags, entry) tuples. The first entry has a duration of 0. """ stop = None @@ -204,13 +237,24 @@ class TimeWindow(object): self.virtual_midnight): start = stop duration = stop - start - yield start, stop, duration, entry + # tags are appended to the entry title, separated by ' -- ' + entry, tags = self._split_entry_and_tags(entry) + yield start, stop, duration, tags, entry + + def set_of_all_tags(self): + """ + Return set of all tags mentioned in entries. + """ + all_tags = set() + for _, _, _, entry_tags, _ in self.all_entries(): + all_tags.update(entry_tags) + return all_tags def count_days(self): """Count days that have entries.""" count = 0 last = None - for start, stop, duration, entry in self.all_entries(): + for start, stop, duration, tags, entry in self.all_entries(): if last is None or different_days(last, start, self.virtual_midnight): last = start @@ -236,7 +280,8 @@ class TimeWindow(object): if different_days(start, stop, self.virtual_midnight): start = stop duration = stop - start - return start, stop, duration, entry + entry, tags = self._split_entry_and_tags(entry) + return start, stop, duration, tags, entry def grouped_entries(self, skip_first=True): """Return consolidated entries (grouped by entry title). @@ -247,7 +292,7 @@ class TimeWindow(object): """ work = {} slack = {} - for start, stop, duration, entry in self.all_entries(): + for start, stop, duration, tags, entry in self.all_entries(): if skip_first: skip_first = False continue @@ -297,9 +342,12 @@ class TimeWindow(object): None, datetime.timedelta(0)) + duration return entries, totals - def totals(self): + def totals(self, tag=None): """Calculate total time of work and slacking entries. + If optional argument `tag` is given, only compute + totals for entries marked with the given tag. + Returns (total_work, total_slacking) tuple. Slacking entries are identified by finding two asterisks in the title. @@ -318,7 +366,9 @@ class TimeWindow(object): (that is, it would be true if sum could operate on timedeltas). """ total_work = total_slacking = datetime.timedelta(0) - for start, stop, duration, entry in self.all_entries(): + for start, stop, duration, tags, entry in self.all_entries(): + if tag is not None and tag not in tags: + continue if '**' in entry: total_slacking += duration else: @@ -330,15 +380,13 @@ class TimeWindow(object): output.write("BEGIN:VCALENDAR\n") output.write("PRODID:-//mg.pov.lt/NONSGML GTimeLog//EN\n") output.write("VERSION:2.0\n") - try: - import socket - idhost = socket.getfqdn() - except: # can it actually ever fail? - idhost = 'localhost' + idhost = socket.getfqdn() dtstamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") - for start, stop, duration, entry in self.all_entries(): + def _hash(start, stop, entry): + return md5(("%s%s%s" % (start, stop, entry)).encode('UTF-8')).hexdigest() + for start, stop, duration, tags, entry in self.all_entries(): output.write("BEGIN:VEVENT\n") - output.write("UID:%s@%s\n" % (hash((start, stop, entry)), idhost)) + output.write("UID:%s@%s\n" % (_hash(start, stop, entry), idhost)) output.write("SUMMARY:%s\n" % (entry.replace('\\', '\\\\')) .replace(';', '\\;') .replace(',', '\\,')) @@ -380,7 +428,7 @@ class TimeWindow(object): d0 = datetime.timedelta(0) days = {} # date -> [time_started, slacking, work] dmin = None - for start, stop, duration, entry in self.all_entries(): + for start, stop, duration, tags, entry in self.all_entries(): if dmin is None: dmin = start.date() day = days.setdefault(start.date(), @@ -400,9 +448,9 @@ class TimeWindow(object): dmin += datetime.timedelta(days=1) # convert to hours, and a sortable list - items = [(day, as_hours(start), as_hours(slacking), as_hours(work)) - for day, (start, slacking, work) in days.items()] - items.sort() + items = sorted( + (day, as_hours(start), as_hours(slacking), as_hours(work)) + for day, (start, slacking, work) in days.items()) writer.writerows(items) @@ -412,8 +460,7 @@ class Reports(object): def __init__(self, window): self.window = window - def _categorizing_report(self, output, email, who, subject, period_name, - estimated_column=False): + def _categorizing_report(self, output, email, who, subject, period_name): """A report that displays entries by category. Writes a report template in RFC-822 format to output. @@ -456,10 +503,7 @@ class Reports(object): output.write("No work done this %s.\n" % period_name) return output.write(" " * 46) - if estimated_column: - output.write("estimated actual\n") - else: - output.write(" time\n") + output.write(" time\n") total_work, total_slacking = window.totals() entries, totals = window.categorized_work_entries() @@ -484,13 +528,8 @@ class Reports(object): continue # skip empty "arrival" entries entry = entry[:1].upper() + entry[1:] - if estimated_column: - output.write(u" %-46s %-14s %s\n" % - (entry, '-', - format_duration_short(duration))) - else: - output.write(u" %-61s %+5s\n" % - (entry, format_duration_short(duration))) + output.write(u" %-61s %+5s\n" % + (entry, format_duration_short(duration))) output.write('-' * 70 + '\n') output.write(u"%+70s\n" % format_duration_short(totals[cat])) @@ -508,6 +547,45 @@ class Reports(object): for time, cat in ordered_by_time: output.write(line_format % (cat, format_duration_short(time))) + tags = self.window.set_of_all_tags() + if tags: + self._report_tags(output, tags) + + def _report_tags(self, output, tags): + """Helper method that lists time spent per tag. + + Use this to add a section in a report looks similar to this: + + sysadmin: 2 hours 1 min + www: 18 hours 45 min + mailserver: 3 hours + + Note that duration may not add up to the total working time, + as a single entry can have multiple or no tags at all! + + Argument `tags` is a set of tags (string). It is not modified. + """ + output.write('\n') + output.write('Time spent in each area:\n') + output.write('\n') + # sum work and slacking time per tag; we do not care in this report + tags_totals = {} + for tag in tags: + spent_working, spent_slacking = self.window.totals(tag) + tags_totals[tag] = spent_working + spent_slacking + # compute width of tag label column + max_tag_length = max([len(tag) for tag in tags_totals.keys()]) + line_format = ' %-' + str(max_tag_length + 4) + 's %+5s\n' + # sort by time spent (descending) + for tag, spent in sorted(tags_totals.items(), + key=(lambda it: it[1]), + reverse=True): + output.write(line_format % (tag, format_duration_short(spent))) + output.write('\n') + output.write( + 'Note that area totals may not add up to the period totals,\n' + 'as each entry may be belong to multiple areas (or none at all).\n') + def _report_categories(self, output, categories): """A helper method that lists time spent per category. @@ -533,8 +611,7 @@ class Reports(object): cat, format_duration_long(duration))) output.write('\n') - def _plain_report(self, output, email, who, subject, period_name, - estimated_column=False): + def _plain_report(self, output, email, who, subject, period_name): """Format a report that does not categorize entries. Writes a report template in RFC-822 format to output. @@ -549,10 +626,7 @@ class Reports(object): output.write("No work done this %s.\n" % period_name) return output.write(" " * 46) - if estimated_column: - output.write("estimated actual\n") - else: - output.write(" time\n") + output.write(" time\n") work, slack = window.grouped_entries() total_work, total_slacking = window.totals() categories = {} @@ -572,12 +646,8 @@ class Reports(object): None, datetime.timedelta(0)) + duration entry = entry[:1].upper() + entry[1:] - if estimated_column: - output.write(u"%-46s %-14s %s\n" % - (entry, '-', format_duration_long(duration))) - else: - output.write(u"%-62s %s\n" % - (entry, format_duration_long(duration))) + output.write(u"%-62s %s\n" % + (entry, format_duration_long(duration))) output.write('\n') output.write("Total work done this %s: %s\n" % (period_name, format_duration_long(total_work))) @@ -585,50 +655,46 @@ class Reports(object): if categories: self._report_categories(output, categories) - def weekly_report_categorized(self, output, email, who, - estimated_column=False): + tags = self.window.set_of_all_tags() + if tags: + self._report_tags(output, tags) + + def weekly_report_categorized(self, output, email, who): """Format a weekly report with entries displayed under categories.""" week = self.window.min_timestamp.isocalendar()[1] subject = u'Weekly report for %s (week %02d)' % (who, week) return self._categorizing_report(output, email, who, subject, - period_name='week', - estimated_column=estimated_column) + period_name='week') - def monthly_report_categorized(self, output, email, who, - estimated_column=False): + def monthly_report_categorized(self, output, email, who): """Format a monthly report with entries displayed under categories.""" month = self.window.min_timestamp.strftime('%Y/%m') subject = u'Monthly report for %s (%s)' % (who, month) return self._categorizing_report(output, email, who, subject, - period_name='month', - estimated_column=estimated_column) + period_name='month') - def weekly_report_plain(self, output, email, who, estimated_column=False): + def weekly_report_plain(self, output, email, who): """Format a weekly report .""" week = self.window.min_timestamp.isocalendar()[1] subject = u'Weekly report for %s (week %02d)' % (who, week) return self._plain_report(output, email, who, subject, - period_name='week', - estimated_column=estimated_column) + period_name='week') - def monthly_report_plain(self, output, email, who, estimated_column=False): + def monthly_report_plain(self, output, email, who): """Format a monthly report .""" month = self.window.min_timestamp.strftime('%Y/%m') subject = u'Monthly report for %s (%s)' % (who, month) return self._plain_report(output, email, who, subject, - period_name='month', - estimated_column=estimated_column) + period_name='month') - def custom_range_report_categorized(self, output, email, who, - estimated_column=False): + def custom_range_report_categorized(self, output, email, who): """Format a custom range report with entries displayed under categories.""" min = self.window.min_timestamp.strftime('%Y-%m-%d') max = self.window.max_timestamp - datetime.timedelta(1) max = max.strftime('%Y-%m-%d') subject = u'Custom date range report for %s (%s - %s)' % (who, min, max) return self._categorizing_report(output, email, who, subject, - period_name='custom range', - estimated_column=estimated_column) + period_name='custom range') def daily_report(self, output, email, who): """Format a daily report. @@ -652,7 +718,7 @@ class Reports(object): if not items: output.write("No work done today.\n") return - start, stop, duration, entry = items[0] + start, stop, duration, tags, entry = items[0] entry = entry[:1].upper() + entry[1:] output.write("%s at %s\n" % (entry, start.strftime('%H:%M'))) output.write('\n') @@ -689,6 +755,10 @@ class Reports(object): output.write("Time spent slacking: %s\n" % format_duration_long(total_slacking)) + tags = self.window.set_of_all_tags() + if tags: + self._report_tags(output, tags) + class TimeLog(object): """Time log. @@ -723,6 +793,10 @@ class TimeLog(object): Returns None if the file doesn't exist. """ + # Accept any file-like object instead of a filename (for the benefit of + # unit tests). + if hasattr(self.filename, 'read'): + return None try: return os.stat(self.filename).st_mtime except OSError: @@ -809,8 +883,15 @@ class TimeLog(object): self.window.items.append((now, entry)) line = '%s: %s' % (now.strftime("%Y-%m-%d %H:%M"), entry) self.raw_append(line) + for (min, max), cached in self._cache.items(): + if cached is not self.window and min <= now < max: + cached.items.append((now, entry)) def valid_time(self, time): + """Is this a valid time for a correction? + + Valid times are those between the last timelog entry and now. + """ if time > datetime.datetime.now(): return False last = self.window.last_time() @@ -818,6 +899,41 @@ class TimeLog(object): return False return True + def parse_correction(self, entry): + """Recognize a time correction. + + Corrections are entries that begin with a timestamp (HH:MM) or a + relative number of minutes (-MM). + + Returns a tuple (entry, timestamp). ``timestamp`` will be None + if no correction was recognized. ``entry`` will have the leading + timestamp stripped. + """ + now = None + date_match = re.match(r'(\d\d):(\d\d)\s+', entry) + delta_match = re.match(r'-([1-9]\d?|1\d\d)\s+', entry) + if date_match: + h = int(date_match.group(1)) + m = int(date_match.group(2)) + if 0 <= h < 24 and 0 <= m < 60: + now = datetime.datetime.combine(self.virtual_today(), + datetime.time(h, m)) + if now.time() < self.virtual_midnight: + now += datetime.timedelta(1) + if self.valid_time(now): + entry = entry[date_match.end():] + else: + now = None + if delta_match: + seconds = int(delta_match.group()) * 60 + now = datetime.datetime.now().replace(second=0, microsecond=0) + now += datetime.timedelta(seconds=seconds) + if self.valid_time(now): + entry = entry[delta_match.end():] + else: + now = None + return entry, now + class TaskList(object): """Task list. @@ -1,16 +1,28 @@ [tox] envlist = - py26,py27,py33 + py27,py33,py34,py35 [testenv] setenv = LC_ALL=C +deps = + freezegun + mock commands = - python setup.py test -q + python setup.py -q test [testenv:coverage] +usedevelop = true deps = + {[testenv]deps} coverage commands = - coverage run -m gtimelog.tests - coverage report + coverage run {posargs} -m gtimelog.tests + +[testenv:coverage3] +basepython = python3 +usedevelop = true +deps = + {[testenv:coverage]deps} +commands = + coverage run {posargs} -m gtimelog.tests |