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

57b73384e91ce9cb2ea63c32545266f0b8aff1e4
[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     def __ne__(self, other):
169         return not self.__eq__(other)
170
171     __repr__ = __unicode__
172
173
174 class Task(TaskResource):
175     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
176
177     class DoesNotExist(Exception):
178         pass
179
180     class CompletedTask(Exception):
181         """
182         Raised when the operation cannot be performed on the completed task.
183         """
184         pass
185
186     class DeletedTask(Exception):
187         """
188         Raised when the operation cannot be performed on the deleted task.
189         """
190         pass
191
192     class ActiveTask(Exception):
193         """
194         Raised when the operation cannot be performed on the active task.
195         """
196         pass
197
198     class InactiveTask(Exception):
199         """
200         Raised when the operation cannot be performed on an inactive task.
201         """
202         pass
203
204     class NotSaved(Exception):
205         """
206         Raised when the operation cannot be performed on the task, because
207         it has not been saved to TaskWarrior yet.
208         """
209         pass
210
211     @classmethod
212     def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
213         """
214         Creates a Task object, directly from the stdin, by reading one line.
215         If modify=True, two lines are used, first line interpreted as the
216         original state of the Task object, and second line as its new,
217         modified value. This is consistent with the TaskWarrior's hook
218         system.
219
220         Object created by this method should not be saved, deleted
221         or refreshed, as t could create a infinite loop. For this
222         reason, TaskWarrior instance is set to None.
223
224         Input_file argument can be used to specify the input file,
225         but defaults to sys.stdin.
226         """
227
228         # Detect the hook type if not given directly
229         name = os.path.basename(sys.argv[0])
230         modify = name.startswith('on-modify') if modify is None else modify
231
232         # Create the TaskWarrior instance if none passed
233         if backend is None:
234             backends = importlib.import_module('tasklib.backends')
235             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
236             backend = backends.TaskWarrior(data_location=hook_parent_dir)
237
238         # TaskWarrior instance is set to None
239         task = cls(backend)
240
241         # Load the data from the input
242         task._load_data(json.loads(input_file.readline().strip()))
243
244         # If this is a on-modify event, we are provided with additional
245         # line of input, which provides updated data
246         if modify:
247             task._update_data(json.loads(input_file.readline().strip()),
248                               remove_missing=True)
249
250         return task
251
252     def __init__(self, backend, **kwargs):
253         super(Task, self).__init__(backend)
254
255         # Check that user is not able to set read-only value in __init__
256         for key in kwargs.keys():
257             if key in self.read_only_fields:
258                 raise RuntimeError('Field \'%s\' is read-only' % key)
259
260         # We serialize the data in kwargs so that users of the library
261         # do not have to pass different data formats via __setitem__ and
262         # __init__ methods, that would be confusing
263
264         # Rather unfortunate syntax due to python2.6 comaptiblity
265         self._data = dict((key, self._normalize(key, value))
266                           for (key, value) in six.iteritems(kwargs))
267         self._original_data = copy.deepcopy(self._data)
268
269         # Provide read only access to the original data
270         self.original = ReadOnlyDictView(self._original_data)
271
272     def __unicode__(self):
273         return self['description']
274
275     def __eq__(self, other):
276         if self['uuid'] and other['uuid']:
277             # For saved Tasks, just define equality by equality of uuids
278             return self['uuid'] == other['uuid']
279         else:
280             # If the tasks are not saved, compare the actual instances
281             return id(self) == id(other)
282
283     def __ne__(self, other):
284         return not self.__eq__(other)
285
286     def __hash__(self):
287         if self['uuid']:
288             # For saved Tasks, just define equality by equality of uuids
289             return self['uuid'].__hash__()
290         else:
291             # If the tasks are not saved, return hash of instance id
292             return id(self).__hash__()
293
294     @property
295     def completed(self):
296         return self['status'] == six.text_type('completed')
297
298     @property
299     def deleted(self):
300         return self['status'] == six.text_type('deleted')
301
302     @property
303     def waiting(self):
304         return self['status'] == six.text_type('waiting')
305
306     @property
307     def pending(self):
308         return self['status'] == six.text_type('pending')
309
310     @property
311     def active(self):
312         return self['start'] is not None
313
314     @property
315     def saved(self):
316         return self['uuid'] is not None or self['id'] is not None
317
318     def serialize_depends(self, cur_dependencies):
319         # Check that all the tasks are saved
320         for task in (cur_dependencies or set()):
321             if not task.saved:
322                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
323                                     'it can be set as dependency.' % task)
324
325         return super(Task, self).serialize_depends(cur_dependencies)
326
327     def delete(self):
328         if not self.saved:
329             raise Task.NotSaved("Task needs to be saved before it can be deleted")
330
331         # Refresh the status, and raise exception if the task is deleted
332         self.refresh(only_fields=['status'])
333
334         if self.deleted:
335             raise Task.DeletedTask("Task was already deleted")
336
337         self.backend.delete_task(self)
338
339         # Refresh the status again, so that we have updated info stored
340         self.refresh(only_fields=['status', 'start', 'end'])
341
342     def start(self):
343         if not self.saved:
344             raise Task.NotSaved("Task needs to be saved before it can be started")
345
346         # Refresh, and raise exception if task is already completed/deleted
347         self.refresh(only_fields=['status'])
348
349         if self.completed:
350             raise Task.CompletedTask("Cannot start a completed task")
351         elif self.deleted:
352             raise Task.DeletedTask("Deleted task cannot be started")
353         elif self.active:
354             raise Task.ActiveTask("Task is already active")
355
356         self.backend.start_task(self)
357
358         # Refresh the status again, so that we have updated info stored
359         self.refresh(only_fields=['status', 'start'])
360
361     def stop(self):
362         if not self.saved:
363             raise Task.NotSaved("Task needs to be saved before it can be stopped")
364
365         # Refresh, and raise exception if task is already completed/deleted
366         self.refresh(only_fields=['status'])
367
368         if not self.active:
369             raise Task.InactiveTask("Cannot stop an inactive task")
370
371         self.backend.stop_task(self)
372
373         # Refresh the status again, so that we have updated info stored
374         self.refresh(only_fields=['status', 'start'])
375
376     def done(self):
377         if not self.saved:
378             raise Task.NotSaved("Task needs to be saved before it can be completed")
379
380         # Refresh, and raise exception if task is already completed/deleted
381         self.refresh(only_fields=['status'])
382
383         if self.completed:
384             raise Task.CompletedTask("Cannot complete a completed task")
385         elif self.deleted:
386             raise Task.DeletedTask("Deleted task cannot be completed")
387
388         self.backend.complete_task(self)
389
390         # Refresh the status again, so that we have updated info stored
391         self.refresh(only_fields=['status', 'start', 'end'])
392
393     def save(self):
394         if self.saved and not self.modified:
395             return
396
397         # All the actual work is done by the backend
398         self.backend.save_task(self)
399
400     def add_annotation(self, annotation):
401         if not self.saved:
402             raise Task.NotSaved("Task needs to be saved to add annotation")
403
404         self.backend.annotate_task(self, annotation)
405         self.refresh(only_fields=['annotations'])
406
407     def remove_annotation(self, annotation):
408         if not self.saved:
409             raise Task.NotSaved("Task needs to be saved to remove annotation")
410
411         if isinstance(annotation, TaskAnnotation):
412             annotation = annotation['description']
413
414         self.backend.denotate_task(self, annotation)
415         self.refresh(only_fields=['annotations'])
416
417     def refresh(self, only_fields=None, after_save=False):
418         # Raise error when trying to refresh a task that has not been saved
419         if not self.saved:
420             raise Task.NotSaved("Task needs to be saved to be refreshed")
421
422         new_data = self.backend.refresh_task(self, after_save=after_save)
423
424         if only_fields:
425             to_update = dict(
426                 [(k, new_data.get(k)) for k in only_fields])
427             self._update_data(to_update, update_original=True)
428         else:
429             self._load_data(new_data)
430
431
432 class TaskQuerySet(object):
433     """
434     Represents a lazy lookup for a task objects.
435     """
436
437     def __init__(self, backend, filter_obj=None):
438         self.backend = backend
439         self._result_cache = None
440         self.filter_obj = filter_obj or self.backend.filter_class(backend)
441
442     def __deepcopy__(self, memo):
443         """
444         Deep copy of a QuerySet doesn't populate the cache
445         """
446         obj = self.__class__(backend=self.backend)
447         for k, v in self.__dict__.items():
448             if k in ('_iter', '_result_cache'):
449                 obj.__dict__[k] = None
450             else:
451                 obj.__dict__[k] = copy.deepcopy(v, memo)
452         return obj
453
454     def __repr__(self):
455         data = list(self[:REPR_OUTPUT_SIZE + 1])
456         if len(data) > REPR_OUTPUT_SIZE:
457             data[-1] = "...(remaining elements truncated)..."
458         return repr(data)
459
460     def __len__(self):
461         if self._result_cache is None:
462             self._result_cache = list(self)
463         return len(self._result_cache)
464
465     def __iter__(self):
466         if self._result_cache is None:
467             self._result_cache = self._execute()
468         return iter(self._result_cache)
469
470     def __getitem__(self, k):
471         if self._result_cache is None:
472             self._result_cache = list(self)
473         return self._result_cache.__getitem__(k)
474
475     def __bool__(self):
476         if self._result_cache is not None:
477             return bool(self._result_cache)
478         try:
479             next(iter(self))
480         except StopIteration:
481             return False
482         return True
483
484     def __nonzero__(self):
485         return type(self).__bool__(self)
486
487     def _clone(self, klass=None, **kwargs):
488         if klass is None:
489             klass = self.__class__
490         filter_obj = self.filter_obj.clone()
491         c = klass(backend=self.backend, filter_obj=filter_obj)
492         c.__dict__.update(kwargs)
493         return c
494
495     def _execute(self):
496         """
497         Fetch the tasks which match the current filters.
498         """
499         return self.backend.filter_tasks(self.filter_obj)
500
501     def all(self):
502         """
503         Returns a new TaskQuerySet that is a copy of the current one.
504         """
505         return self._clone()
506
507     def pending(self):
508         return self.filter(status=PENDING)
509
510     def completed(self):
511         return self.filter(status=COMPLETED)
512
513     def filter(self, *args, **kwargs):
514         """
515         Returns a new TaskQuerySet with the given filters added.
516         """
517         clone = self._clone()
518         for f in args:
519             clone.filter_obj.add_filter(f)
520         for key, value in kwargs.items():
521             clone.filter_obj.add_filter_param(key, value)
522         return clone
523
524     def get(self, **kwargs):
525         """
526         Performs the query and returns a single object matching the given
527         keyword arguments.
528         """
529         clone = self.filter(**kwargs)
530         num = len(clone)
531         if num == 1:
532             return clone._result_cache[0]
533         if not num:
534             raise Task.DoesNotExist(
535                 'Task matching query does not exist. '
536                 'Lookup parameters were {0}'.format(kwargs))
537         raise ValueError(
538             'get() returned more than one Task -- it returned {0}! '
539             'Lookup parameters were {1}'.format(num, kwargs))