From: Tomas Babej Date: Sat, 17 Jan 2015 10:39:24 +0000 (+0100) Subject: Merge pull request #24 from tbabej/hooks2 X-Git-Url: https://git.madduck.net/etc/taskwarrior.git/commitdiff_plain/ddaa580dc2b2051c80bb6bd1de9c999c46f9ee12?hp=58f3ecf4ed9351d3226b3ac0152ec56313f79431 Merge pull request #24 from tbabej/hooks2 Better hook support --- diff --git a/docs/index.rst b/docs/index.rst index 44ef469..5c8b387 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -309,6 +309,44 @@ To pass your own configuration, you just need to update this dictionary:: >>> tw.config.update({'hooks': 'off'}) # tasklib will not trigger hooks +Creating hook scripts +--------------------- + +From version 2.4.0, TaskWarrior has support for hook scripts. Tasklib provides +some very useful helpers to write those. With tasklib, writing these becomes +a breeze:: + + #!/usr/bin/python + + from tasklib.task import Task + task = Task.from_input() + # ... + print task.export_data() + +For example, plugin which would assign the priority "H" to any task containing +three exclamation marks in the description, would go like this:: + + #!/usr/bin/python + + from tasklib.task import Task + task = Task.from_input() + + if "!!!" in task['description']: + task['priority'] = "H" + + print task.export_data() + +Tasklib can automatically detect whether it's running in the ``on-modify`` event, +which provides more input than ``on-add`` event and reads the data accordingly. + +This means the example above works both for ``on-add`` and ``on-modify`` events! + +Consenquently, you can create just one hook file for both ``on-add`` and +``on-modify`` events, and you just need to create a symlink for the other one. +This removes the need for maintaining two copies of the same code base and/or +boilerplate code. + + Working with UDAs ----------------- diff --git a/tasklib/task.py b/tasklib/task.py index f61fb05..6de828f 100644 --- a/tasklib/task.py +++ b/tasklib/task.py @@ -5,6 +5,7 @@ import json import logging import os import six +import sys import subprocess DATE_FORMAT = '%Y%m%dT%H%M%SZ' @@ -205,6 +206,40 @@ class Task(TaskResource): """ pass + @classmethod + def from_input(cls, input_file=sys.stdin, modify=None): + """ + Creates a Task object, directly from the stdin, by reading one line. + If modify=True, two lines are used, first line interpreted as the + original state of the Task object, and second line as its new, + modified value. This is consistent with the TaskWarrior's hook + system. + + Object created by this method should not be saved, deleted + or refreshed, as t could create a infinite loop. For this + reason, TaskWarrior instance is set to None. + + Input_file argument can be used to specify the input file, + but defaults to sys.stdin. + """ + + # TaskWarrior instance is set to None + task = cls(None) + + # Detect the hook type if not given directly + name = os.path.basename(sys.argv[0]) + modify = name.startswith('on-modify') if modify is None else modify + + # Load the data from the input + task._load_data(json.loads(input_file.readline().strip())) + + # If this is a on-modify event, we are provided with additional + # line of input, which provides updated data + if modify: + task._update_data(json.loads(input_file.readline().strip())) + + return task + def __init__(self, warrior, **kwargs): self.warrior = warrior @@ -257,7 +292,7 @@ class Task(TaskResource): yield key @property - def _is_modified(self): + def modified(self): return bool(list(self._modified_fields)) @property @@ -350,7 +385,7 @@ class Task(TaskResource): self.refresh(only_fields=['status']) def save(self): - if self.saved and not self._is_modified: + if self.saved and not self.modified: return args = [self['uuid'], 'modify'] if self.saved else ['add'] @@ -370,6 +405,9 @@ class Task(TaskResource): # Circumvent the ID storage, since ID is considered read-only self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.')) + # Refreshing is very important here, as not only modification time + # is updated, but arbitrary attribute may have changed due hooks + # altering the data before saving self.refresh() def add_annotation(self, annotation): @@ -435,6 +473,20 @@ class Task(TaskResource): else: self._load_data(new_data) + def export_data(self): + """ + Exports current data contained in the Task as JSON + """ + + # We need to remove spaces for TW-1504, use custom separators + data_tuples = ((key, self._serialize(key, value)) + for key, value in six.iteritems(self._data)) + + # Empty string denotes empty serialized value, we do not want + # to pass that to TaskWarrior. + data_tuples = filter(lambda t: t[1] is not '', data_tuples) + data = dict(data_tuples) + return json.dumps(data, separators=(',',':')) class TaskFilter(SerializingObject): """ diff --git a/tasklib/tests.py b/tasklib/tests.py index 35979a8..d498f09 100644 --- a/tasklib/tests.py +++ b/tasklib/tests.py @@ -1,6 +1,8 @@ # coding=utf-8 import datetime +import itertools +import six import shutil import tempfile import unittest @@ -476,6 +478,57 @@ class TaskTest(TasklibTest): self.assertEqual(t['tags'], ['test']) +class TaskFromHookTest(TasklibTest): + + input_add_data = six.StringIO( + '{"description":"Buy some milk",' + '"entry":"20141118T050231Z",' + '"status":"pending",' + '"uuid":"a360fc44-315c-4366-b70c-ea7e7520b749"}') + + input_modify_data = six.StringIO(input_add_data.getvalue() + '\n' + + '{"description":"Buy some milk finally",' + '"entry":"20141118T050231Z",' + '"status":"completed",' + '"uuid":"a360fc44-315c-4366-b70c-ea7e7520b749"}') + + exported_raw_data = ( + '{"project":"Home",' + '"due":"20150101T232323Z",' + '"description":"test task"}') + + def test_setting_up_from_add_hook_input(self): + t = Task.from_input(input_file=self.input_add_data) + self.assertEqual(t['description'], "Buy some milk") + self.assertEqual(t.pending, True) + + def test_setting_up_from_modified_hook_input(self): + t = Task.from_input(input_file=self.input_modify_data, modify=True) + self.assertEqual(t['description'], "Buy some milk finally") + self.assertEqual(t.pending, False) + self.assertEqual(t.completed, True) + + self.assertEqual(t._original_data['status'], "pending") + self.assertEqual(t._original_data['description'], "Buy some milk") + self.assertEqual(set(t._modified_fields), + set(['status', 'description'])) + + def test_export_data(self): + t = Task(self.tw, description="test task", + project="Home", due=datetime.datetime(2015,1,1,23,23,23)) + + # Check that the output is a permutation of: + # {"project":"Home","description":"test task","due":"20150101232323Z"} + allowed_segments = self.exported_raw_data[1:-1].split(',') + allowed_output = [ + '{' + ','.join(segments) + '}' + for segments in itertools.permutations(allowed_segments) + ] + + self.assertTrue(any(t.export_data() == expected + for expected in allowed_output)) + + class AnnotationTest(TasklibTest): def setUp(self):