]> git.madduck.net Git - etc/taskwarrior.git/blob - tasklib/task.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

TaskQuerySet: Add recurring native filter
[etc/taskwarrior.git] / tasklib / task.py
1 from __future__ import print_function
2 import copy
3 import importlib
4 import json
5 import logging
6 import os
7 import six
8 import sys
9
10 from .serializing import SerializingObject
11
12 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
13 REPR_OUTPUT_SIZE = 10
14 PENDING = 'pending'
15 COMPLETED = 'completed'
16 DELETED = 'deleted'
17 WAITING = 'waiting'
18 RECURRING = 'recurring'
19
20 logger = logging.getLogger(__name__)
21
22
23 class ReadOnlyDictView(object):
24     """
25     Provides simplified read-only view upon dict object.
26     """
27
28     def __init__(self, viewed_dict):
29         self.viewed_dict = viewed_dict
30
31     def __getitem__(self, key):
32         return copy.deepcopy(self.viewed_dict.__getitem__(key))
33
34     def __contains__(self, k):
35         return self.viewed_dict.__contains__(k)
36
37     def __iter__(self):
38         for value in self.viewed_dict:
39             yield copy.deepcopy(value)
40
41     def __len__(self):
42         return len(self.viewed_dict)
43
44     def __unicode__(self):
45         return six.u('ReadOnlyDictView: {0}'.format(repr(self.viewed_dict)))
46
47     __repr__ = __unicode__
48
49     def get(self, key, default=None):
50         return copy.deepcopy(self.viewed_dict.get(key, default))
51
52     def items(self):
53         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
54
55     def values(self):
56         return [copy.deepcopy(v) for v in self.viewed_dict.values()]
57
58
59 class TaskResource(SerializingObject):
60     read_only_fields = []
61
62     def _load_data(self, data):
63         self._data = dict((key, self._deserialize(key, value))
64                           for key, value in data.items())
65         # We need to use a copy for original data, so that changes
66         # are not propagated.
67         self._original_data = copy.deepcopy(self._data)
68
69     def _update_data(self, data, update_original=False, remove_missing=False):
70         """
71         Low level update of the internal _data dict. Data which are coming as
72         updates should already be serialized. If update_original is True, the
73         original_data dict is updated as well.
74         """
75         self._data.update(dict((key, self._deserialize(key, value))
76                                for key, value in data.items()))
77
78         # In certain situations, we want to treat missing keys as removals
79         if remove_missing:
80             for key in set(self._data.keys()) - set(data.keys()):
81                 self._data[key] = None
82
83         if update_original:
84             self._original_data = copy.deepcopy(self._data)
85
86     def __getitem__(self, key):
87         # This is a workaround to make TaskResource non-iterable
88         # over simple index-based iteration
89         try:
90             int(key)
91             raise StopIteration
92         except ValueError:
93             pass
94
95         if key not in self._data:
96             self._data[key] = self._deserialize(key, None)
97
98         return self._data.get(key)
99
100     def __setitem__(self, key, value):
101         if key in self.read_only_fields:
102             raise RuntimeError('Field \'%s\' is read-only' % key)
103
104         # Normalize the user input before saving it
105         value = self._normalize(key, value)
106         self._data[key] = value
107
108     def __str__(self):
109         s = six.text_type(self.__unicode__())
110         if not six.PY3:
111             s = s.encode('utf-8')
112         return s
113
114     def __repr__(self):
115         return str(self)
116
117     def export_data(self):
118         """
119         Exports current data contained in the Task as JSON
120         """
121
122         # We need to remove spaces for TW-1504, use custom separators
123         data_tuples = ((key, self._serialize(key, value))
124                        for key, value in six.iteritems(self._data))
125
126         # Empty string denotes empty serialized value, we do not want
127         # to pass that to TaskWarrior.
128         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
129         data = dict(data_tuples)
130         return json.dumps(data, separators=(',', ':'))
131
132     @property
133     def _modified_fields(self):
134         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
135         for key in writable_fields:
136             new_value = self._data.get(key)
137             old_value = self._original_data.get(key)
138
139             # Make sure not to mark data removal as modified field if the
140             # field originally had some empty value
141             if key in self._data and not new_value and not old_value:
142                 continue
143
144             if new_value != old_value:
145                 yield key
146
147     @property
148     def modified(self):
149         return bool(list(self._modified_fields))
150
151
152 class TaskAnnotation(TaskResource):
153     read_only_fields = ['entry', 'description']
154
155     def __init__(self, task, data=None):
156         self.task = task
157         self._load_data(data or dict())
158         super(TaskAnnotation, self).__init__(task.backend)
159
160     def remove(self):
161         self.task.remove_annotation(self)
162
163     def __unicode__(self):
164         return self['description']
165
166     def __eq__(self, other):
167         # consider 2 annotations equal if they belong to the same task, and
168         # their data dics are the same
169         return self.task == other.task and self._data == other._data
170
171     def __ne__(self, other):
172         return not self.__eq__(other)
173
174     __repr__ = __unicode__
175
176
177 class Task(TaskResource):
178     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
179
180     class DoesNotExist(Exception):
181         pass
182
183     class CompletedTask(Exception):
184         """
185         Raised when the operation cannot be performed on the completed task.
186         """
187         pass
188
189     class DeletedTask(Exception):
190         """
191         Raised when the operation cannot be performed on the deleted task.
192         """
193         pass
194
195     class ActiveTask(Exception):
196         """
197         Raised when the operation cannot be performed on the active task.
198         """
199         pass
200
201     class InactiveTask(Exception):
202         """
203         Raised when the operation cannot be performed on an inactive task.
204         """
205         pass
206
207     class NotSaved(Exception):
208         """
209         Raised when the operation cannot be performed on the task, because
210         it has not been saved to TaskWarrior yet.
211         """
212         pass
213
214     @classmethod
215     def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
216         """
217         Creates a Task object, directly from the stdin, by reading one line.
218         If modify=True, two lines are used, first line interpreted as the
219         original state of the Task object, and second line as its new,
220         modified value. This is consistent with the TaskWarrior's hook
221         system.
222
223         Object created by this method should not be saved, deleted
224         or refreshed, as t could create a infinite loop. For this
225         reason, TaskWarrior instance is set to None.
226
227         Input_file argument can be used to specify the input file,
228         but defaults to sys.stdin.
229         """
230
231         # Detect the hook type if not given directly
232         name = os.path.basename(sys.argv[0])
233         modify = name.startswith('on-modify') if modify is None else modify
234
235         # Create the TaskWarrior instance if none passed
236         if backend is None:
237             backends = importlib.import_module('tasklib.backends')
238             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
239             backend = backends.TaskWarrior(data_location=hook_parent_dir)
240
241         # TaskWarrior instance is set to None
242         task = cls(backend)
243
244         # Load the data from the input
245         task._load_data(json.loads(input_file.readline().strip()))
246
247         # If this is a on-modify event, we are provided with additional
248         # line of input, which provides updated data
249         if modify:
250             task._update_data(json.loads(input_file.readline().strip()),
251                               remove_missing=True)
252
253         return task
254
255     def __init__(self, backend, **kwargs):
256         super(Task, self).__init__(backend)
257
258         # Check that user is not able to set read-only value in __init__
259         for key in kwargs.keys():
260             if key in self.read_only_fields:
261                 raise RuntimeError('Field \'%s\' is read-only' % key)
262
263         # We serialize the data in kwargs so that users of the library
264         # do not have to pass different data formats via __setitem__ and
265         # __init__ methods, that would be confusing
266
267         # Rather unfortunate syntax due to python2.6 comaptiblity
268         self._data = dict((key, self._normalize(key, value))
269                           for (key, value) in six.iteritems(kwargs))
270         self._original_data = copy.deepcopy(self._data)
271
272         # Provide read only access to the original data
273         self.original = ReadOnlyDictView(self._original_data)
274
275     def __unicode__(self):
276         return self['description']
277
278     def __eq__(self, other):
279         if self['uuid'] and other['uuid']:
280             # For saved Tasks, just define equality by equality of uuids
281             return self['uuid'] == other['uuid']
282         else:
283             # If the tasks are not saved, compare the actual instances
284             return id(self) == id(other)
285
286     def __ne__(self, other):
287         return not self.__eq__(other)
288
289     def __hash__(self):
290         if self['uuid']:
291             # For saved Tasks, just define equality by equality of uuids
292             return self['uuid'].__hash__()
293         else:
294             # If the tasks are not saved, return hash of instance id
295             return id(self).__hash__()
296
297     @property
298     def completed(self):
299         return self['status'] == six.text_type('completed')
300
301     @property
302     def deleted(self):
303         return self['status'] == six.text_type('deleted')
304
305     @property
306     def waiting(self):
307         return self['status'] == six.text_type('waiting')
308
309     @property
310     def pending(self):
311         return self['status'] == six.text_type('pending')
312
313     @property
314     def recurring(self):
315         return self['status'] == six.text_type('recurring')
316
317     @property
318     def active(self):
319         return self['start'] is not None
320
321     @property
322     def saved(self):
323         return self['uuid'] is not None or self['id'] is not None
324
325     def serialize_depends(self, cur_dependencies):
326         # Check that all the tasks are saved
327         for task in (cur_dependencies or set()):
328             if not task.saved:
329                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
330                                     'it can be set as dependency.' % task)
331
332         return super(Task, self).serialize_depends(cur_dependencies)
333
334     def delete(self):
335         if not self.saved:
336             raise Task.NotSaved("Task needs to be saved before it can be deleted")
337
338         # Refresh the status, and raise exception if the task is deleted
339         self.refresh(only_fields=['status'])
340
341         if self.deleted:
342             raise Task.DeletedTask("Task was already deleted")
343
344         self.backend.delete_task(self)
345
346         # Refresh the status again, so that we have updated info stored
347         self.refresh(only_fields=['status', 'start', 'end'])
348
349     def start(self):
350         if not self.saved:
351             raise Task.NotSaved("Task needs to be saved before it can be started")
352
353         # Refresh, and raise exception if task is already completed/deleted
354         self.refresh(only_fields=['status'])
355
356         if self.completed:
357             raise Task.CompletedTask("Cannot start a completed task")
358         elif self.deleted:
359             raise Task.DeletedTask("Deleted task cannot be started")
360         elif self.active:
361             raise Task.ActiveTask("Task is already active")
362
363         self.backend.start_task(self)
364
365         # Refresh the status again, so that we have updated info stored
366         self.refresh(only_fields=['status', 'start'])
367
368     def stop(self):
369         if not self.saved:
370             raise Task.NotSaved("Task needs to be saved before it can be stopped")
371
372         # Refresh, and raise exception if task is already completed/deleted
373         self.refresh(only_fields=['status'])
374
375         if not self.active:
376             raise Task.InactiveTask("Cannot stop an inactive task")
377
378         self.backend.stop_task(self)
379
380         # Refresh the status again, so that we have updated info stored
381         self.refresh(only_fields=['status', 'start'])
382
383     def done(self):
384         if not self.saved:
385             raise Task.NotSaved("Task needs to be saved before it can be completed")
386
387         # Refresh, and raise exception if task is already completed/deleted
388         self.refresh(only_fields=['status'])
389
390         if self.completed:
391             raise Task.CompletedTask("Cannot complete a completed task")
392         elif self.deleted:
393             raise Task.DeletedTask("Deleted task cannot be completed")
394
395         self.backend.complete_task(self)
396
397         # Refresh the status again, so that we have updated info stored
398         self.refresh(only_fields=['status', 'start', 'end'])
399
400     def save(self):
401         if self.saved and not self.modified:
402             return
403
404         # All the actual work is done by the backend
405         self.backend.save_task(self)
406
407     def add_annotation(self, annotation):
408         if not self.saved:
409             raise Task.NotSaved("Task needs to be saved to add annotation")
410
411         self.backend.annotate_task(self, annotation)
412         self.refresh(only_fields=['annotations'])
413
414     def remove_annotation(self, annotation):
415         if not self.saved:
416             raise Task.NotSaved("Task needs to be saved to remove annotation")
417
418         if isinstance(annotation, TaskAnnotation):
419             annotation = annotation['description']
420
421         self.backend.denotate_task(self, annotation)
422         self.refresh(only_fields=['annotations'])
423
424     def refresh(self, only_fields=None, after_save=False):
425         # Raise error when trying to refresh a task that has not been saved
426         if not self.saved:
427             raise Task.NotSaved("Task needs to be saved to be refreshed")
428
429         new_data = self.backend.refresh_task(self, after_save=after_save)
430
431         if only_fields:
432             to_update = dict(
433                 [(k, new_data.get(k)) for k in only_fields])
434             self._update_data(to_update, update_original=True)
435         else:
436             self._load_data(new_data)
437
438
439 class TaskQuerySet(object):
440     """
441     Represents a lazy lookup for a task objects.
442     """
443
444     def __init__(self, backend, filter_obj=None):
445         self.backend = backend
446         self._result_cache = None
447         self.filter_obj = filter_obj or self.backend.filter_class(backend)
448
449     def __deepcopy__(self, memo):
450         """
451         Deep copy of a QuerySet doesn't populate the cache
452         """
453         obj = self.__class__(backend=self.backend)
454         for k, v in self.__dict__.items():
455             if k in ('_iter', '_result_cache'):
456                 obj.__dict__[k] = None
457             else:
458                 obj.__dict__[k] = copy.deepcopy(v, memo)
459         return obj
460
461     def __repr__(self):
462         data = list(self[:REPR_OUTPUT_SIZE + 1])
463         if len(data) > REPR_OUTPUT_SIZE:
464             data[-1] = "...(remaining elements truncated)..."
465         return repr(data)
466
467     def __len__(self):
468         if self._result_cache is None:
469             self._result_cache = list(self)
470         return len(self._result_cache)
471
472     def __iter__(self):
473         if self._result_cache is None:
474             self._result_cache = self._execute()
475         return iter(self._result_cache)
476
477     def __getitem__(self, k):
478         if self._result_cache is None:
479             self._result_cache = list(self)
480         return self._result_cache.__getitem__(k)
481
482     def __bool__(self):
483         if self._result_cache is not None:
484             return bool(self._result_cache)
485         try:
486             next(iter(self))
487         except StopIteration:
488             return False
489         return True
490
491     def __nonzero__(self):
492         return type(self).__bool__(self)
493
494     def _clone(self, klass=None, **kwargs):
495         if klass is None:
496             klass = self.__class__
497         filter_obj = self.filter_obj.clone()
498         c = klass(backend=self.backend, filter_obj=filter_obj)
499         c.__dict__.update(kwargs)
500         return c
501
502     def _execute(self):
503         """
504         Fetch the tasks which match the current filters.
505         """
506         return self.backend.filter_tasks(self.filter_obj)
507
508     def all(self):
509         """
510         Returns a new TaskQuerySet that is a copy of the current one.
511         """
512         return self._clone()
513
514     def pending(self):
515         return self.filter(status=PENDING)
516
517     def completed(self):
518         return self.filter(status=COMPLETED)
519
520     def deleted(self):
521         return self.filter(status=DELETED)
522
523     def waiting(self):
524         return self.filter(status=WAITING)
525
526     def recurring(self):
527         return self.filter(status=RECURRING)
528
529     def filter(self, *args, **kwargs):
530         """
531         Returns a new TaskQuerySet with the given filters added.
532         """
533         clone = self._clone()
534         for f in args:
535             clone.filter_obj.add_filter(f)
536         for key, value in kwargs.items():
537             clone.filter_obj.add_filter_param(key, value)
538         return clone
539
540     def get(self, **kwargs):
541         """
542         Performs the query and returns a single object matching the given
543         keyword arguments.
544         """
545         clone = self.filter(**kwargs)
546         num = len(clone)
547         if num == 1:
548             return clone._result_cache[0]
549         if not num:
550             raise Task.DoesNotExist(
551                 'Task matching query does not exist. '
552                 'Lookup parameters were {0}'.format(kwargs))
553         raise ValueError(
554             'get() returned more than one Task -- it returned {0}! '
555             'Lookup parameters were {1}'.format(num, kwargs))