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

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