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

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