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()):
330 'Task \'%s\' needs to be saved before '
331 'it can be set as dependency.' % task,
334 return super(Task, self).serialize_depends(cur_dependencies)
339 'Task needs to be saved before it can be deleted',
342 # Refresh the status, and raise exception if the task is deleted
343 self.refresh(only_fields=['status'])
346 raise Task.DeletedTask('Task was already deleted')
348 self.backend.delete_task(self)
350 # Refresh the status again, so that we have updated info stored
351 self.refresh(only_fields=['status', 'start', 'end'])
356 'Task needs to be saved before it can be started',
359 # Refresh, and raise exception if task is already completed/deleted
360 self.refresh(only_fields=['status'])
363 raise Task.CompletedTask('Cannot start a completed task')
365 raise Task.DeletedTask('Deleted task cannot be started')
367 raise Task.ActiveTask('Task is already active')
369 self.backend.start_task(self)
371 # Refresh the status again, so that we have updated info stored
372 self.refresh(only_fields=['status', 'start'])
377 'Task needs to be saved before it can be stopped',
380 # Refresh, and raise exception if task is already completed/deleted
381 self.refresh(only_fields=['status'])
384 raise Task.InactiveTask('Cannot stop an inactive task')
386 self.backend.stop_task(self)
388 # Refresh the status again, so that we have updated info stored
389 self.refresh(only_fields=['status', 'start'])
394 'Task needs to be saved before it can be completed',
397 # Refresh, and raise exception if task is already completed/deleted
398 self.refresh(only_fields=['status'])
401 raise Task.CompletedTask('Cannot complete a completed task')
403 raise Task.DeletedTask('Deleted task cannot be completed')
405 self.backend.complete_task(self)
407 # Refresh the status again, so that we have updated info stored
408 self.refresh(only_fields=['status', 'start', 'end'])
411 if self.saved and not self.modified:
414 # All the actual work is done by the backend
415 self.backend.save_task(self)
417 def add_annotation(self, annotation):
419 raise Task.NotSaved('Task needs to be saved to add annotation')
421 self.backend.annotate_task(self, annotation)
422 self.refresh(only_fields=['annotations'])
424 def remove_annotation(self, annotation):
426 raise Task.NotSaved('Task needs to be saved to remove annotation')
428 if isinstance(annotation, TaskAnnotation):
429 annotation = annotation['description']
431 self.backend.denotate_task(self, annotation)
432 self.refresh(only_fields=['annotations'])
434 def refresh(self, only_fields=None, after_save=False):
435 # Raise error when trying to refresh a task that has not been saved
437 raise Task.NotSaved('Task needs to be saved to be refreshed')
439 new_data = self.backend.refresh_task(self, after_save=after_save)
443 [(k, new_data.get(k)) for k in only_fields],
445 self._update_data(to_update, update_original=True)
447 self._load_data(new_data)
450 class TaskQuerySet(object):
452 Represents a lazy lookup for a task objects.
455 def __init__(self, backend, filter_obj=None):
456 self.backend = backend
457 self._result_cache = None
458 self.filter_obj = filter_obj or self.backend.filter_class(backend)
460 def __deepcopy__(self, memo):
462 Deep copy of a QuerySet doesn't populate the cache
464 obj = self.__class__(backend=self.backend)
465 for k, v in self.__dict__.items():
466 if k in ('_iter', '_result_cache'):
467 obj.__dict__[k] = None
469 obj.__dict__[k] = copy.deepcopy(v, memo)
473 data = list(self[:REPR_OUTPUT_SIZE + 1])
474 if len(data) > REPR_OUTPUT_SIZE:
475 data[-1] = '...(remaining elements truncated)...'
479 if self._result_cache is None:
480 self._result_cache = list(self)
481 return len(self._result_cache)
484 if self._result_cache is None:
485 self._result_cache = self._execute()
486 return iter(self._result_cache)
488 def __getitem__(self, k):
489 if self._result_cache is None:
490 self._result_cache = list(self)
491 return self._result_cache.__getitem__(k)
494 if self._result_cache is not None:
495 return bool(self._result_cache)
498 except StopIteration:
502 def __nonzero__(self):
503 return type(self).__bool__(self)
505 def _clone(self, klass=None, **kwargs):
507 klass = self.__class__
508 filter_obj = self.filter_obj.clone()
509 c = klass(backend=self.backend, filter_obj=filter_obj)
510 c.__dict__.update(kwargs)
515 Fetch the tasks which match the current filters.
517 return self.backend.filter_tasks(self.filter_obj)
521 Returns a new TaskQuerySet that is a copy of the current one.
526 return self.filter(status=PENDING)
529 return self.filter(status=COMPLETED)
532 return self.filter(status=DELETED)
535 return self.filter(status=WAITING)
538 return self.filter(status=RECURRING)
540 def filter(self, *args, **kwargs):
542 Returns a new TaskQuerySet with the given filters added.
544 clone = self._clone()
546 clone.filter_obj.add_filter(f)
547 for key, value in kwargs.items():
548 clone.filter_obj.add_filter_param(key, value)
551 def get(self, **kwargs):
553 Performs the query and returns a single object matching the given
556 clone = self.filter(**kwargs)
559 return clone._result_cache[0]
561 raise Task.DoesNotExist(
562 'Task matching query does not exist. '
563 'Lookup parameters were {0}'.format(kwargs),
566 'get() returned more than one Task -- it returned {0}! '
567 'Lookup parameters were {1}'.format(num, kwargs),