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)
337 "Task needs to be saved before it can be deleted")
339 # Refresh the status, and raise exception if the task is deleted
340 self.refresh(only_fields=['status'])
343 raise Task.DeletedTask("Task was already deleted")
345 self.backend.delete_task(self)
347 # Refresh the status again, so that we have updated info stored
348 self.refresh(only_fields=['status', 'start', 'end'])
353 "Task needs to be saved before it can be started")
355 # Refresh, and raise exception if task is already completed/deleted
356 self.refresh(only_fields=['status'])
359 raise Task.CompletedTask("Cannot start a completed task")
361 raise Task.DeletedTask("Deleted task cannot be started")
363 raise Task.ActiveTask("Task is already active")
365 self.backend.start_task(self)
367 # Refresh the status again, so that we have updated info stored
368 self.refresh(only_fields=['status', 'start'])
373 "Task needs to be saved before it can be stopped")
375 # Refresh, and raise exception if task is already completed/deleted
376 self.refresh(only_fields=['status'])
379 raise Task.InactiveTask("Cannot stop an inactive task")
381 self.backend.stop_task(self)
383 # Refresh the status again, so that we have updated info stored
384 self.refresh(only_fields=['status', 'start'])
389 "Task needs to be saved before it can be completed")
391 # Refresh, and raise exception if task is already completed/deleted
392 self.refresh(only_fields=['status'])
395 raise Task.CompletedTask("Cannot complete a completed task")
397 raise Task.DeletedTask("Deleted task cannot be completed")
399 self.backend.complete_task(self)
401 # Refresh the status again, so that we have updated info stored
402 self.refresh(only_fields=['status', 'start', 'end'])
405 if self.saved and not self.modified:
408 # All the actual work is done by the backend
409 self.backend.save_task(self)
411 def add_annotation(self, annotation):
413 raise Task.NotSaved("Task needs to be saved to add annotation")
415 self.backend.annotate_task(self, annotation)
416 self.refresh(only_fields=['annotations'])
418 def remove_annotation(self, annotation):
420 raise Task.NotSaved("Task needs to be saved to remove annotation")
422 if isinstance(annotation, TaskAnnotation):
423 annotation = annotation['description']
425 self.backend.denotate_task(self, annotation)
426 self.refresh(only_fields=['annotations'])
428 def refresh(self, only_fields=None, after_save=False):
429 # Raise error when trying to refresh a task that has not been saved
431 raise Task.NotSaved("Task needs to be saved to be refreshed")
433 new_data = self.backend.refresh_task(self, after_save=after_save)
437 [(k, new_data.get(k)) for k in only_fields])
438 self._update_data(to_update, update_original=True)
440 self._load_data(new_data)
443 class TaskQuerySet(object):
445 Represents a lazy lookup for a task objects.
448 def __init__(self, backend, filter_obj=None):
449 self.backend = backend
450 self._result_cache = None
451 self.filter_obj = filter_obj or self.backend.filter_class(backend)
453 def __deepcopy__(self, memo):
455 Deep copy of a QuerySet doesn't populate the cache
457 obj = self.__class__(backend=self.backend)
458 for k, v in self.__dict__.items():
459 if k in ('_iter', '_result_cache'):
460 obj.__dict__[k] = None
462 obj.__dict__[k] = copy.deepcopy(v, memo)
466 data = list(self[:REPR_OUTPUT_SIZE + 1])
467 if len(data) > REPR_OUTPUT_SIZE:
468 data[-1] = "...(remaining elements truncated)..."
472 if self._result_cache is None:
473 self._result_cache = list(self)
474 return len(self._result_cache)
477 if self._result_cache is None:
478 self._result_cache = self._execute()
479 return iter(self._result_cache)
481 def __getitem__(self, k):
482 if self._result_cache is None:
483 self._result_cache = list(self)
484 return self._result_cache.__getitem__(k)
487 if self._result_cache is not None:
488 return bool(self._result_cache)
491 except StopIteration:
495 def __nonzero__(self):
496 return type(self).__bool__(self)
498 def _clone(self, klass=None, **kwargs):
500 klass = self.__class__
501 filter_obj = self.filter_obj.clone()
502 c = klass(backend=self.backend, filter_obj=filter_obj)
503 c.__dict__.update(kwargs)
508 Fetch the tasks which match the current filters.
510 return self.backend.filter_tasks(self.filter_obj)
514 Returns a new TaskQuerySet that is a copy of the current one.
519 return self.filter(status=PENDING)
522 return self.filter(status=COMPLETED)
525 return self.filter(status=DELETED)
528 return self.filter(status=WAITING)
531 return self.filter(status=RECURRING)
533 def filter(self, *args, **kwargs):
535 Returns a new TaskQuerySet with the given filters added.
537 clone = self._clone()
539 clone.filter_obj.add_filter(f)
540 for key, value in kwargs.items():
541 clone.filter_obj.add_filter_param(key, value)
544 def get(self, **kwargs):
546 Performs the query and returns a single object matching the given
549 clone = self.filter(**kwargs)
552 return clone._result_cache[0]
554 raise Task.DoesNotExist(
555 'Task matching query does not exist. '
556 'Lookup parameters were {0}'.format(kwargs))
558 'get() returned more than one Task -- it returned {0}! '
559 'Lookup parameters were {1}'.format(num, kwargs))