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'
18 RECURRING = 'recurring'
20 logger = logging.getLogger(__name__)
23 class ReadOnlyDictView(object):
25 Provides simplified read-only view upon dict object.
28 def __init__(self, viewed_dict):
29 self.viewed_dict = viewed_dict
31 def __getitem__(self, key):
32 return copy.deepcopy(self.viewed_dict.__getitem__(key))
34 def __contains__(self, k):
35 return self.viewed_dict.__contains__(k)
38 for value in self.viewed_dict:
39 yield copy.deepcopy(value)
42 return len(self.viewed_dict)
44 def __unicode__(self):
45 return six.u('ReadOnlyDictView: {0}'.format(repr(self.viewed_dict)))
47 __repr__ = __unicode__
49 def get(self, key, default=None):
50 return copy.deepcopy(self.viewed_dict.get(key, default))
53 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
56 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
59 class TaskResource(SerializingObject):
62 def _load_data(self, data):
63 self._data = dict((key, self._deserialize(key, value))
64 for key, value in data.items())
65 # We need to use a copy for original data, so that changes
67 self._original_data = copy.deepcopy(self._data)
69 def _update_data(self, data, update_original=False, remove_missing=False):
71 Low level update of the internal _data dict. Data which are coming as
72 updates should already be serialized. If update_original is True, the
73 original_data dict is updated as well.
75 self._data.update(dict((key, self._deserialize(key, value))
76 for key, value in data.items()))
78 # In certain situations, we want to treat missing keys as removals
80 for key in set(self._data.keys()) - set(data.keys()):
81 self._data[key] = None
84 self._original_data = copy.deepcopy(self._data)
86 def __getitem__(self, key):
87 # This is a workaround to make TaskResource non-iterable
88 # over simple index-based iteration
95 if key not in self._data:
96 self._data[key] = self._deserialize(key, None)
98 return self._data.get(key)
100 def __setitem__(self, key, value):
101 if key in self.read_only_fields:
102 raise RuntimeError('Field \'%s\' is read-only' % key)
104 # Normalize the user input before saving it
105 value = self._normalize(key, value)
106 self._data[key] = value
109 s = six.text_type(self.__unicode__())
111 s = s.encode('utf-8')
117 def export_data(self):
119 Exports current data contained in the Task as JSON
122 # We need to remove spaces for TW-1504, use custom separators
123 data_tuples = ((key, self._serialize(key, value))
124 for key, value in six.iteritems(self._data))
126 # Empty string denotes empty serialized value, we do not want
127 # to pass that to TaskWarrior.
128 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
129 data = dict(data_tuples)
130 return json.dumps(data, separators=(',', ':'))
133 def _modified_fields(self):
134 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
135 for key in writable_fields:
136 new_value = self._data.get(key)
137 old_value = self._original_data.get(key)
139 # Make sure not to mark data removal as modified field if the
140 # field originally had some empty value
141 if key in self._data and not new_value and not old_value:
144 if new_value != old_value:
149 return bool(list(self._modified_fields))
152 class TaskAnnotation(TaskResource):
153 read_only_fields = ['entry', 'description']
155 def __init__(self, task, data=None):
157 self._load_data(data or dict())
158 super(TaskAnnotation, self).__init__(task.backend)
161 self.task.remove_annotation(self)
163 def __unicode__(self):
164 return self['description']
166 def __eq__(self, other):
167 # consider 2 annotations equal if they belong to the same task, and
168 # their data dics are the same
169 return self.task == other.task and self._data == other._data
171 def __ne__(self, other):
172 return not self.__eq__(other)
174 __repr__ = __unicode__
177 class Task(TaskResource):
178 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
180 class DoesNotExist(Exception):
183 class CompletedTask(Exception):
185 Raised when the operation cannot be performed on the completed task.
189 class DeletedTask(Exception):
191 Raised when the operation cannot be performed on the deleted task.
195 class ActiveTask(Exception):
197 Raised when the operation cannot be performed on the active task.
201 class InactiveTask(Exception):
203 Raised when the operation cannot be performed on an inactive task.
207 class NotSaved(Exception):
209 Raised when the operation cannot be performed on the task, because
210 it has not been saved to TaskWarrior yet.
215 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
217 Creates a Task object, directly from the stdin, by reading one line.
218 If modify=True, two lines are used, first line interpreted as the
219 original state of the Task object, and second line as its new,
220 modified value. This is consistent with the TaskWarrior's hook
223 Object created by this method should not be saved, deleted
224 or refreshed, as t could create a infinite loop. For this
225 reason, TaskWarrior instance is set to None.
227 Input_file argument can be used to specify the input file,
228 but defaults to sys.stdin.
231 # Detect the hook type if not given directly
232 name = os.path.basename(sys.argv[0])
233 modify = name.startswith('on-modify') if modify is None else modify
235 # Create the TaskWarrior instance if none passed
237 backends = importlib.import_module('tasklib.backends')
238 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
239 backend = backends.TaskWarrior(data_location=hook_parent_dir)
241 # TaskWarrior instance is set to None
244 # Load the data from the input
245 task._load_data(json.loads(input_file.readline().strip()))
247 # If this is a on-modify event, we are provided with additional
248 # line of input, which provides updated data
250 task._update_data(json.loads(input_file.readline().strip()),
255 def __init__(self, backend, **kwargs):
256 super(Task, self).__init__(backend)
258 # Check that user is not able to set read-only value in __init__
259 for key in kwargs.keys():
260 if key in self.read_only_fields:
261 raise RuntimeError('Field \'%s\' is read-only' % key)
263 # We serialize the data in kwargs so that users of the library
264 # do not have to pass different data formats via __setitem__ and
265 # __init__ methods, that would be confusing
267 # Rather unfortunate syntax due to python2.6 comaptiblity
268 self._data = dict((key, self._normalize(key, value))
269 for (key, value) in six.iteritems(kwargs))
270 self._original_data = copy.deepcopy(self._data)
272 # Provide read only access to the original data
273 self.original = ReadOnlyDictView(self._original_data)
275 def __unicode__(self):
276 return self['description']
278 def __eq__(self, other):
279 if self['uuid'] and other['uuid']:
280 # For saved Tasks, just define equality by equality of uuids
281 return self['uuid'] == other['uuid']
283 # If the tasks are not saved, compare the actual instances
284 return id(self) == id(other)
286 def __ne__(self, other):
287 return not self.__eq__(other)
291 # For saved Tasks, just define equality by equality of uuids
292 return self['uuid'].__hash__()
294 # If the tasks are not saved, return hash of instance id
295 return id(self).__hash__()
299 return self['status'] == six.text_type('completed')
303 return self['status'] == six.text_type('deleted')
307 return self['status'] == six.text_type('waiting')
311 return self['status'] == six.text_type('pending')
315 return self['status'] == six.text_type('recurring')
319 return self['start'] is not None
323 return self['uuid'] is not None or self['id'] is not None
325 def serialize_depends(self, cur_dependencies):
326 # Check that all the tasks are saved
327 for task in (cur_dependencies or set()):
329 raise Task.NotSaved('Task \'%s\' needs to be saved before '
330 'it can be set as dependency.' % task)
332 return super(Task, self).serialize_depends(cur_dependencies)
336 raise Task.NotSaved("Task needs to be saved before it can be deleted")
338 # Refresh the status, and raise exception if the task is deleted
339 self.refresh(only_fields=['status'])
342 raise Task.DeletedTask("Task was already deleted")
344 self.backend.delete_task(self)
346 # Refresh the status again, so that we have updated info stored
347 self.refresh(only_fields=['status', 'start', 'end'])
351 raise Task.NotSaved("Task needs to be saved before it can be started")
353 # Refresh, and raise exception if task is already completed/deleted
354 self.refresh(only_fields=['status'])
357 raise Task.CompletedTask("Cannot start a completed task")
359 raise Task.DeletedTask("Deleted task cannot be started")
361 raise Task.ActiveTask("Task is already active")
363 self.backend.start_task(self)
365 # Refresh the status again, so that we have updated info stored
366 self.refresh(only_fields=['status', 'start'])
370 raise Task.NotSaved("Task needs to be saved before it can be stopped")
372 # Refresh, and raise exception if task is already completed/deleted
373 self.refresh(only_fields=['status'])
376 raise Task.InactiveTask("Cannot stop an inactive task")
378 self.backend.stop_task(self)
380 # Refresh the status again, so that we have updated info stored
381 self.refresh(only_fields=['status', 'start'])
385 raise Task.NotSaved("Task needs to be saved before it can be completed")
387 # Refresh, and raise exception if task is already completed/deleted
388 self.refresh(only_fields=['status'])
391 raise Task.CompletedTask("Cannot complete a completed task")
393 raise Task.DeletedTask("Deleted task cannot be completed")
395 self.backend.complete_task(self)
397 # Refresh the status again, so that we have updated info stored
398 self.refresh(only_fields=['status', 'start', 'end'])
401 if self.saved and not self.modified:
404 # All the actual work is done by the backend
405 self.backend.save_task(self)
407 def add_annotation(self, annotation):
409 raise Task.NotSaved("Task needs to be saved to add annotation")
411 self.backend.annotate_task(self, annotation)
412 self.refresh(only_fields=['annotations'])
414 def remove_annotation(self, annotation):
416 raise Task.NotSaved("Task needs to be saved to remove annotation")
418 if isinstance(annotation, TaskAnnotation):
419 annotation = annotation['description']
421 self.backend.denotate_task(self, annotation)
422 self.refresh(only_fields=['annotations'])
424 def refresh(self, only_fields=None, after_save=False):
425 # Raise error when trying to refresh a task that has not been saved
427 raise Task.NotSaved("Task needs to be saved to be refreshed")
429 new_data = self.backend.refresh_task(self, after_save=after_save)
433 [(k, new_data.get(k)) for k in only_fields])
434 self._update_data(to_update, update_original=True)
436 self._load_data(new_data)
439 class TaskQuerySet(object):
441 Represents a lazy lookup for a task objects.
444 def __init__(self, backend, filter_obj=None):
445 self.backend = backend
446 self._result_cache = None
447 self.filter_obj = filter_obj or self.backend.filter_class(backend)
449 def __deepcopy__(self, memo):
451 Deep copy of a QuerySet doesn't populate the cache
453 obj = self.__class__(backend=self.backend)
454 for k, v in self.__dict__.items():
455 if k in ('_iter', '_result_cache'):
456 obj.__dict__[k] = None
458 obj.__dict__[k] = copy.deepcopy(v, memo)
462 data = list(self[:REPR_OUTPUT_SIZE + 1])
463 if len(data) > REPR_OUTPUT_SIZE:
464 data[-1] = "...(remaining elements truncated)..."
468 if self._result_cache is None:
469 self._result_cache = list(self)
470 return len(self._result_cache)
473 if self._result_cache is None:
474 self._result_cache = self._execute()
475 return iter(self._result_cache)
477 def __getitem__(self, k):
478 if self._result_cache is None:
479 self._result_cache = list(self)
480 return self._result_cache.__getitem__(k)
483 if self._result_cache is not None:
484 return bool(self._result_cache)
487 except StopIteration:
491 def __nonzero__(self):
492 return type(self).__bool__(self)
494 def _clone(self, klass=None, **kwargs):
496 klass = self.__class__
497 filter_obj = self.filter_obj.clone()
498 c = klass(backend=self.backend, filter_obj=filter_obj)
499 c.__dict__.update(kwargs)
504 Fetch the tasks which match the current filters.
506 return self.backend.filter_tasks(self.filter_obj)
510 Returns a new TaskQuerySet that is a copy of the current one.
515 return self.filter(status=PENDING)
518 return self.filter(status=COMPLETED)
521 return self.filter(status=DELETED)
524 return self.filter(status=WAITING)
527 return self.filter(status=RECURRING)
529 def filter(self, *args, **kwargs):
531 Returns a new TaskQuerySet with the given filters added.
533 clone = self._clone()
535 clone.filter_obj.add_filter(f)
536 for key, value in kwargs.items():
537 clone.filter_obj.add_filter_param(key, value)
540 def get(self, **kwargs):
542 Performs the query and returns a single object matching the given
545 clone = self.filter(**kwargs)
548 return clone._result_cache[0]
550 raise Task.DoesNotExist(
551 'Task matching query does not exist. '
552 'Lookup parameters were {0}'.format(kwargs))
554 'get() returned more than one Task -- it returned {0}! '
555 'Lookup parameters were {1}'.format(num, kwargs))