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

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