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

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