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

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