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

6e3e4321c9315a10520f235fc03e804bbc490e81
[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 active(self):
314         return self['start'] is not None
315
316     @property
317     def saved(self):
318         return self['uuid'] is not None or self['id'] is not None
319
320     def serialize_depends(self, cur_dependencies):
321         # Check that all the tasks are saved
322         for task in (cur_dependencies or set()):
323             if not task.saved:
324                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
325                                     'it can be set as dependency.' % task)
326
327         return super(Task, self).serialize_depends(cur_dependencies)
328
329     def delete(self):
330         if not self.saved:
331             raise Task.NotSaved("Task needs to be saved before it can be deleted")
332
333         # Refresh the status, and raise exception if the task is deleted
334         self.refresh(only_fields=['status'])
335
336         if self.deleted:
337             raise Task.DeletedTask("Task was already deleted")
338
339         self.backend.delete_task(self)
340
341         # Refresh the status again, so that we have updated info stored
342         self.refresh(only_fields=['status', 'start', 'end'])
343
344     def start(self):
345         if not self.saved:
346             raise Task.NotSaved("Task needs to be saved before it can be started")
347
348         # Refresh, and raise exception if task is already completed/deleted
349         self.refresh(only_fields=['status'])
350
351         if self.completed:
352             raise Task.CompletedTask("Cannot start a completed task")
353         elif self.deleted:
354             raise Task.DeletedTask("Deleted task cannot be started")
355         elif self.active:
356             raise Task.ActiveTask("Task is already active")
357
358         self.backend.start_task(self)
359
360         # Refresh the status again, so that we have updated info stored
361         self.refresh(only_fields=['status', 'start'])
362
363     def stop(self):
364         if not self.saved:
365             raise Task.NotSaved("Task needs to be saved before it can be stopped")
366
367         # Refresh, and raise exception if task is already completed/deleted
368         self.refresh(only_fields=['status'])
369
370         if not self.active:
371             raise Task.InactiveTask("Cannot stop an inactive task")
372
373         self.backend.stop_task(self)
374
375         # Refresh the status again, so that we have updated info stored
376         self.refresh(only_fields=['status', 'start'])
377
378     def done(self):
379         if not self.saved:
380             raise Task.NotSaved("Task needs to be saved before it can be completed")
381
382         # Refresh, and raise exception if task is already completed/deleted
383         self.refresh(only_fields=['status'])
384
385         if self.completed:
386             raise Task.CompletedTask("Cannot complete a completed task")
387         elif self.deleted:
388             raise Task.DeletedTask("Deleted task cannot be completed")
389
390         self.backend.complete_task(self)
391
392         # Refresh the status again, so that we have updated info stored
393         self.refresh(only_fields=['status', 'start', 'end'])
394
395     def save(self):
396         if self.saved and not self.modified:
397             return
398
399         # All the actual work is done by the backend
400         self.backend.save_task(self)
401
402     def add_annotation(self, annotation):
403         if not self.saved:
404             raise Task.NotSaved("Task needs to be saved to add annotation")
405
406         self.backend.annotate_task(self, annotation)
407         self.refresh(only_fields=['annotations'])
408
409     def remove_annotation(self, annotation):
410         if not self.saved:
411             raise Task.NotSaved("Task needs to be saved to remove annotation")
412
413         if isinstance(annotation, TaskAnnotation):
414             annotation = annotation['description']
415
416         self.backend.denotate_task(self, annotation)
417         self.refresh(only_fields=['annotations'])
418
419     def refresh(self, only_fields=None, after_save=False):
420         # Raise error when trying to refresh a task that has not been saved
421         if not self.saved:
422             raise Task.NotSaved("Task needs to be saved to be refreshed")
423
424         new_data = self.backend.refresh_task(self, after_save=after_save)
425
426         if only_fields:
427             to_update = dict(
428                 [(k, new_data.get(k)) for k in only_fields])
429             self._update_data(to_update, update_original=True)
430         else:
431             self._load_data(new_data)
432
433
434 class TaskQuerySet(object):
435     """
436     Represents a lazy lookup for a task objects.
437     """
438
439     def __init__(self, backend, filter_obj=None):
440         self.backend = backend
441         self._result_cache = None
442         self.filter_obj = filter_obj or self.backend.filter_class(backend)
443
444     def __deepcopy__(self, memo):
445         """
446         Deep copy of a QuerySet doesn't populate the cache
447         """
448         obj = self.__class__(backend=self.backend)
449         for k, v in self.__dict__.items():
450             if k in ('_iter', '_result_cache'):
451                 obj.__dict__[k] = None
452             else:
453                 obj.__dict__[k] = copy.deepcopy(v, memo)
454         return obj
455
456     def __repr__(self):
457         data = list(self[:REPR_OUTPUT_SIZE + 1])
458         if len(data) > REPR_OUTPUT_SIZE:
459             data[-1] = "...(remaining elements truncated)..."
460         return repr(data)
461
462     def __len__(self):
463         if self._result_cache is None:
464             self._result_cache = list(self)
465         return len(self._result_cache)
466
467     def __iter__(self):
468         if self._result_cache is None:
469             self._result_cache = self._execute()
470         return iter(self._result_cache)
471
472     def __getitem__(self, k):
473         if self._result_cache is None:
474             self._result_cache = list(self)
475         return self._result_cache.__getitem__(k)
476
477     def __bool__(self):
478         if self._result_cache is not None:
479             return bool(self._result_cache)
480         try:
481             next(iter(self))
482         except StopIteration:
483             return False
484         return True
485
486     def __nonzero__(self):
487         return type(self).__bool__(self)
488
489     def _clone(self, klass=None, **kwargs):
490         if klass is None:
491             klass = self.__class__
492         filter_obj = self.filter_obj.clone()
493         c = klass(backend=self.backend, filter_obj=filter_obj)
494         c.__dict__.update(kwargs)
495         return c
496
497     def _execute(self):
498         """
499         Fetch the tasks which match the current filters.
500         """
501         return self.backend.filter_tasks(self.filter_obj)
502
503     def all(self):
504         """
505         Returns a new TaskQuerySet that is a copy of the current one.
506         """
507         return self._clone()
508
509     def pending(self):
510         return self.filter(status=PENDING)
511
512     def completed(self):
513         return self.filter(status=COMPLETED)
514
515     def deleted(self):
516         return self.filter(status=DELETED)
517
518     def waiting(self):
519         return self.filter(status=WAITING)
520
521     def filter(self, *args, **kwargs):
522         """
523         Returns a new TaskQuerySet with the given filters added.
524         """
525         clone = self._clone()
526         for f in args:
527             clone.filter_obj.add_filter(f)
528         for key, value in kwargs.items():
529             clone.filter_obj.add_filter_param(key, value)
530         return clone
531
532     def get(self, **kwargs):
533         """
534         Performs the query and returns a single object matching the given
535         keyword arguments.
536         """
537         clone = self.filter(**kwargs)
538         num = len(clone)
539         if num == 1:
540             return clone._result_cache[0]
541         if not num:
542             raise Task.DoesNotExist(
543                 'Task matching query does not exist. '
544                 'Lookup parameters were {0}'.format(kwargs))
545         raise ValueError(
546             'get() returned more than one Task -- it returned {0}! '
547             'Lookup parameters were {1}'.format(num, kwargs))