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

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