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

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