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.
1 from __future__ import print_function
10 from .serializing import SerializingObject
12 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
15 COMPLETED = 'completed'
17 logger = logging.getLogger(__name__)
20 class ReadOnlyDictView(object):
22 Provides simplified read-only view upon dict object.
25 def __init__(self, viewed_dict):
26 self.viewed_dict = viewed_dict
28 def __getitem__(self, key):
29 return copy.deepcopy(self.viewed_dict.__getitem__(key))
31 def __contains__(self, k):
32 return self.viewed_dict.__contains__(k)
35 for value in self.viewed_dict:
36 yield copy.deepcopy(value)
39 return len(self.viewed_dict)
41 def __unicode__(self):
42 return six.u('ReadOnlyDictView: {0}'.format(repr(self.viewed_dict)))
44 __repr__ = __unicode__
46 def get(self, key, default=None):
47 return copy.deepcopy(self.viewed_dict.get(key, default))
50 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
53 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
56 class TaskResource(SerializingObject):
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
64 self._original_data = copy.deepcopy(self._data)
66 def _update_data(self, data, update_original=False, remove_missing=False):
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.
72 self._data.update(dict((key, self._deserialize(key, value))
73 for key, value in data.items()))
75 # In certain situations, we want to treat missing keys as removals
77 for key in set(self._data.keys()) - set(data.keys()):
78 self._data[key] = None
81 self._original_data = copy.deepcopy(self._data)
83 def __getitem__(self, key):
84 # This is a workaround to make TaskResource non-iterable
85 # over simple index-based iteration
92 if key not in self._data:
93 self._data[key] = self._deserialize(key, None)
95 return self._data.get(key)
97 def __setitem__(self, key, value):
98 if key in self.read_only_fields:
99 raise RuntimeError('Field \'%s\' is read-only' % key)
101 # Normalize the user input before saving it
102 value = self._normalize(key, value)
103 self._data[key] = value
106 s = six.text_type(self.__unicode__())
108 s = s.encode('utf-8')
114 def export_data(self):
116 Exports current data contained in the Task as JSON
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))
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=(',', ':'))
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)
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:
141 if new_value != old_value:
146 return bool(list(self._modified_fields))
149 class TaskAnnotation(TaskResource):
150 read_only_fields = ['entry', 'description']
152 def __init__(self, task, data=None):
154 self._load_data(data or dict())
155 super(TaskAnnotation, self).__init__(task.backend)
158 self.task.remove_annotation(self)
160 def __unicode__(self):
161 return self['description']
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
168 __repr__ = __unicode__
171 class LazyUUIDTask(object):
173 A lazy wrapper around Task object, referenced by UUID.
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
181 def __init__(self, tw, uuid):
185 def __getitem__(self, key):
186 # LazyUUIDTask does not provide anything else other than 'uuid'
193 def __getattr__(self, name):
194 # Getattr is called only if the attribute could not be found using
199 def __eq__(self, other):
201 # For saved Tasks, just define equality by equality of uuids
202 return self['uuid'] == other['uuid']
205 return self['uuid'].__hash__()
209 Performs conversion to the regular Task object, referenced by the
213 replacement = self._tw.tasks.get(uuid=self._uuid)
214 self.__class__ = replacement.__class__
215 self.__dict__ = replacement.__dict__
218 class LazyUUIDTaskSet(object):
220 A lazy wrapper around TaskQuerySet object, for tasks referenced by UUID.
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
227 def __init__(self, tw, uuids):
229 self._uuids = set(uuids)
231 def __getattr__(self, name):
232 # Getattr is called only if the attribute could not be found using
237 def __eq__(self, other):
238 return set(t['uuid'] for t in other) == self._uuids
240 def __contains__(self, task):
241 return task['uuid'] in self._uuids
244 return len(self._uuids)
253 Performs conversion to the regular TaskQuerySet object, referenced by
257 replacement = self._tw.tasks.filter(' '.join(self._uuids))
258 self.__class__ = replacement.__class__
259 self.__dict__ = replacement.__dict__
262 class Task(TaskResource):
263 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
265 class DoesNotExist(Exception):
268 class CompletedTask(Exception):
270 Raised when the operation cannot be performed on the completed task.
274 class DeletedTask(Exception):
276 Raised when the operation cannot be performed on the deleted task.
280 class ActiveTask(Exception):
282 Raised when the operation cannot be performed on the active task.
286 class InactiveTask(Exception):
288 Raised when the operation cannot be performed on an inactive task.
292 class NotSaved(Exception):
294 Raised when the operation cannot be performed on the task, because
295 it has not been saved to TaskWarrior yet.
300 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
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
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.
312 Input_file argument can be used to specify the input file,
313 but defaults to sys.stdin.
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
320 # Create the TaskWarrior instance if none passed
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)
326 # TaskWarrior instance is set to None
329 # Load the data from the input
330 task._load_data(json.loads(input_file.readline().strip()))
332 # If this is a on-modify event, we are provided with additional
333 # line of input, which provides updated data
335 task._update_data(json.loads(input_file.readline().strip()),
340 def __init__(self, backend, **kwargs):
341 super(Task, self).__init__(backend)
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)
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
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)
357 # Provide read only access to the original data
358 self.original = ReadOnlyDictView(self._original_data)
360 def __unicode__(self):
361 return self['description']
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']
368 # If the tasks are not saved, compare the actual instances
369 return id(self) == id(other)
373 # For saved Tasks, just define equality by equality of uuids
374 return self['uuid'].__hash__()
376 # If the tasks are not saved, return hash of instance id
377 return id(self).__hash__()
381 return self['status'] == six.text_type('completed')
385 return self['status'] == six.text_type('deleted')
389 return self['status'] == six.text_type('waiting')
393 return self['status'] == six.text_type('pending')
397 return self['start'] is not None
401 return self['uuid'] is not None or self['id'] is not None
403 def serialize_depends(self, cur_dependencies):
404 # Check that all the tasks are saved
405 for task in (cur_dependencies or set()):
407 raise Task.NotSaved('Task \'%s\' needs to be saved before '
408 'it can be set as dependency.' % task)
410 return super(Task, self).serialize_depends(cur_dependencies)
414 raise Task.NotSaved("Task needs to be saved before it can be deleted")
416 # Refresh the status, and raise exception if the task is deleted
417 self.refresh(only_fields=['status'])
420 raise Task.DeletedTask("Task was already deleted")
422 self.backend.delete_task(self)
424 # Refresh the status again, so that we have updated info stored
425 self.refresh(only_fields=['status', 'start', 'end'])
429 raise Task.NotSaved("Task needs to be saved before it can be started")
431 # Refresh, and raise exception if task is already completed/deleted
432 self.refresh(only_fields=['status'])
435 raise Task.CompletedTask("Cannot start a completed task")
437 raise Task.DeletedTask("Deleted task cannot be started")
439 raise Task.ActiveTask("Task is already active")
441 self.backend.start_task(self)
443 # Refresh the status again, so that we have updated info stored
444 self.refresh(only_fields=['status', 'start'])
448 raise Task.NotSaved("Task needs to be saved before it can be stopped")
450 # Refresh, and raise exception if task is already completed/deleted
451 self.refresh(only_fields=['status'])
454 raise Task.InactiveTask("Cannot stop an inactive task")
456 self.backend.stop_task(self)
458 # Refresh the status again, so that we have updated info stored
459 self.refresh(only_fields=['status', 'start'])
463 raise Task.NotSaved("Task needs to be saved before it can be completed")
465 # Refresh, and raise exception if task is already completed/deleted
466 self.refresh(only_fields=['status'])
469 raise Task.CompletedTask("Cannot complete a completed task")
471 raise Task.DeletedTask("Deleted task cannot be completed")
473 self.backend.complete_task(self)
475 # Refresh the status again, so that we have updated info stored
476 self.refresh(only_fields=['status', 'start', 'end'])
479 if self.saved and not self.modified:
482 # All the actual work is done by the backend
483 self.backend.save_task(self)
485 def add_annotation(self, annotation):
487 raise Task.NotSaved("Task needs to be saved to add annotation")
489 self.backend.annotate_task(self, annotation)
490 self.refresh(only_fields=['annotations'])
492 def remove_annotation(self, annotation):
494 raise Task.NotSaved("Task needs to be saved to remove annotation")
496 if isinstance(annotation, TaskAnnotation):
497 annotation = annotation['description']
499 self.backend.denotate_task(self, annotation)
500 self.refresh(only_fields=['annotations'])
502 def refresh(self, only_fields=None, after_save=False):
503 # Raise error when trying to refresh a task that has not been saved
505 raise Task.NotSaved("Task needs to be saved to be refreshed")
507 new_data = self.backend.refresh_task(self, after_save=after_save)
511 [(k, new_data.get(k)) for k in only_fields])
512 self._update_data(to_update, update_original=True)
514 self._load_data(new_data)
517 class TaskQuerySet(object):
519 Represents a lazy lookup for a task objects.
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)
527 def __deepcopy__(self, memo):
529 Deep copy of a QuerySet doesn't populate the cache
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
536 obj.__dict__[k] = copy.deepcopy(v, memo)
540 data = list(self[:REPR_OUTPUT_SIZE + 1])
541 if len(data) > REPR_OUTPUT_SIZE:
542 data[-1] = "...(remaining elements truncated)..."
546 if self._result_cache is None:
547 self._result_cache = list(self)
548 return len(self._result_cache)
551 if self._result_cache is None:
552 self._result_cache = self._execute()
553 return iter(self._result_cache)
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)
561 if self._result_cache is not None:
562 return bool(self._result_cache)
565 except StopIteration:
569 def __nonzero__(self):
570 return type(self).__bool__(self)
572 def _clone(self, klass=None, **kwargs):
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)
582 Fetch the tasks which match the current filters.
584 return self.backend.filter_tasks(self.filter_obj)
588 Returns a new TaskQuerySet that is a copy of the current one.
593 return self.filter(status=PENDING)
596 return self.filter(status=COMPLETED)
598 def filter(self, *args, **kwargs):
600 Returns a new TaskQuerySet with the given filters added.
602 clone = self._clone()
604 clone.filter_obj.add_filter(f)
605 for key, value in kwargs.items():
606 clone.filter_obj.add_filter_param(key, value)
609 def get(self, **kwargs):
611 Performs the query and returns a single object matching the given
614 clone = self.filter(**kwargs)
617 return clone._result_cache[0]
619 raise Task.DoesNotExist(
620 'Task matching query does not exist. '
621 'Lookup parameters were {0}'.format(kwargs))
623 'get() returned more than one Task -- it returned {0}! '
624 'Lookup parameters were {1}'.format(num, kwargs))