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

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