]> 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:

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