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

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