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 backends import TaskWarrior, TaskWarriorException
11 from serializing import SerializingObject
13 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
16 COMPLETED = 'completed'
18 logger = logging.getLogger(__name__)
21 class ReadOnlyDictView(object):
23 Provides simplified read-only view upon dict object.
26 def __init__(self, viewed_dict):
27 self.viewed_dict = viewed_dict
29 def __getitem__(self, key):
30 return copy.deepcopy(self.viewed_dict.__getitem__(key))
32 def __contains__(self, k):
33 return self.viewed_dict.__contains__(k)
36 for value in self.viewed_dict:
37 yield copy.deepcopy(value)
40 return len(self.viewed_dict)
42 def get(self, key, default=None):
43 return copy.deepcopy(self.viewed_dict.get(key, default))
46 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
49 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
52 class TaskResource(SerializingObject):
55 def _load_data(self, data):
56 self._data = dict((key, self._deserialize(key, value))
57 for key, value in data.items())
58 # We need to use a copy for original data, so that changes
60 self._original_data = copy.deepcopy(self._data)
62 def _update_data(self, data, update_original=False, remove_missing=False):
64 Low level update of the internal _data dict. Data which are coming as
65 updates should already be serialized. If update_original is True, the
66 original_data dict is updated as well.
68 self._data.update(dict((key, self._deserialize(key, value))
69 for key, value in data.items()))
71 # In certain situations, we want to treat missing keys as removals
73 for key in set(self._data.keys()) - set(data.keys()):
74 self._data[key] = None
77 self._original_data = copy.deepcopy(self._data)
80 def __getitem__(self, key):
81 # This is a workaround to make TaskResource non-iterable
82 # over simple index-based iteration
89 if key not in self._data:
90 self._data[key] = self._deserialize(key, None)
92 return self._data.get(key)
94 def __setitem__(self, key, value):
95 if key in self.read_only_fields:
96 raise RuntimeError('Field \'%s\' is read-only' % key)
98 # Normalize the user input before saving it
99 value = self._normalize(key, value)
100 self._data[key] = value
103 s = six.text_type(self.__unicode__())
105 s = s.encode('utf-8')
111 def export_data(self):
113 Exports current data contained in the Task as JSON
116 # We need to remove spaces for TW-1504, use custom separators
117 data_tuples = ((key, self._serialize(key, value))
118 for key, value in six.iteritems(self._data))
120 # Empty string denotes empty serialized value, we do not want
121 # to pass that to TaskWarrior.
122 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
123 data = dict(data_tuples)
124 return json.dumps(data, separators=(',',':'))
127 def _modified_fields(self):
128 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
129 for key in writable_fields:
130 new_value = self._data.get(key)
131 old_value = self._original_data.get(key)
133 # Make sure not to mark data removal as modified field if the
134 # field originally had some empty value
135 if key in self._data and not new_value and not old_value:
138 if new_value != old_value:
143 return bool(list(self._modified_fields))
146 class TaskAnnotation(TaskResource):
147 read_only_fields = ['entry', 'description']
149 def __init__(self, task, data=None):
151 self._load_data(data or dict())
152 super(TaskAnnotation, self).__init__(task.warrior)
155 self.task.remove_annotation(self)
157 def __unicode__(self):
158 return self['description']
160 def __eq__(self, other):
161 # consider 2 annotations equal if they belong to the same task, and
162 # their data dics are the same
163 return self.task == other.task and self._data == other._data
165 __repr__ = __unicode__
168 class Task(TaskResource):
169 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
171 class DoesNotExist(Exception):
174 class CompletedTask(Exception):
176 Raised when the operation cannot be performed on the completed task.
180 class DeletedTask(Exception):
182 Raised when the operation cannot be performed on the deleted task.
186 class ActiveTask(Exception):
188 Raised when the operation cannot be performed on the active task.
192 class InactiveTask(Exception):
194 Raised when the operation cannot be performed on an inactive task.
198 class NotSaved(Exception):
200 Raised when the operation cannot be performed on the task, because
201 it has not been saved to TaskWarrior yet.
206 def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
208 Creates a Task object, directly from the stdin, by reading one line.
209 If modify=True, two lines are used, first line interpreted as the
210 original state of the Task object, and second line as its new,
211 modified value. This is consistent with the TaskWarrior's hook
214 Object created by this method should not be saved, deleted
215 or refreshed, as t could create a infinite loop. For this
216 reason, TaskWarrior instance is set to None.
218 Input_file argument can be used to specify the input file,
219 but defaults to sys.stdin.
222 # Detect the hook type if not given directly
223 name = os.path.basename(sys.argv[0])
224 modify = name.startswith('on-modify') if modify is None else modify
226 # Create the TaskWarrior instance if none passed
228 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
229 warrior = TaskWarrior(data_location=hook_parent_dir)
231 # TaskWarrior instance is set to None
234 # Load the data from the input
235 task._load_data(json.loads(input_file.readline().strip()))
237 # If this is a on-modify event, we are provided with additional
238 # line of input, which provides updated data
240 task._update_data(json.loads(input_file.readline().strip()),
245 def __init__(self, warrior, **kwargs):
246 super(Task, self).__init__(warrior)
248 # Check that user is not able to set read-only value in __init__
249 for key in kwargs.keys():
250 if key in self.read_only_fields:
251 raise RuntimeError('Field \'%s\' is read-only' % key)
253 # We serialize the data in kwargs so that users of the library
254 # do not have to pass different data formats via __setitem__ and
255 # __init__ methods, that would be confusing
257 # Rather unfortunate syntax due to python2.6 comaptiblity
258 self._data = dict((key, self._normalize(key, value))
259 for (key, value) in six.iteritems(kwargs))
260 self._original_data = copy.deepcopy(self._data)
262 # Provide read only access to the original data
263 self.original = ReadOnlyDictView(self._original_data)
265 def __unicode__(self):
266 return self['description']
268 def __eq__(self, other):
269 if self['uuid'] and other['uuid']:
270 # For saved Tasks, just define equality by equality of uuids
271 return self['uuid'] == other['uuid']
273 # If the tasks are not saved, compare the actual instances
274 return id(self) == id(other)
279 # For saved Tasks, just define equality by equality of uuids
280 return self['uuid'].__hash__()
282 # If the tasks are not saved, return hash of instance id
283 return id(self).__hash__()
287 return self['status'] == six.text_type('completed')
291 return self['status'] == six.text_type('deleted')
295 return self['status'] == six.text_type('waiting')
299 return self['status'] == six.text_type('pending')
303 return self['start'] is not None
307 return self['uuid'] is not None or self['id'] is not None
309 def serialize_depends(self, cur_dependencies):
310 # Check that all the tasks are saved
311 for task in (cur_dependencies or set()):
313 raise Task.NotSaved('Task \'%s\' needs to be saved before '
314 'it can be set as dependency.' % task)
316 return super(Task, self).serialize_depends(cur_dependencies)
320 raise Task.NotSaved("Task needs to be saved before it can be deleted")
322 # Refresh the status, and raise exception if the task is deleted
323 self.refresh(only_fields=['status'])
326 raise Task.DeletedTask("Task was already deleted")
328 self.backend.delete_task(self)
330 # Refresh the status again, so that we have updated info stored
331 self.refresh(only_fields=['status', 'start', 'end'])
335 raise Task.NotSaved("Task needs to be saved before it can be started")
337 # Refresh, and raise exception if task is already completed/deleted
338 self.refresh(only_fields=['status'])
341 raise Task.CompletedTask("Cannot start a completed task")
343 raise Task.DeletedTask("Deleted task cannot be started")
345 raise Task.ActiveTask("Task is already active")
347 self.backend.start_task(self)
349 # Refresh the status again, so that we have updated info stored
350 self.refresh(only_fields=['status', 'start'])
354 raise Task.NotSaved("Task needs to be saved before it can be stopped")
356 # Refresh, and raise exception if task is already completed/deleted
357 self.refresh(only_fields=['status'])
360 raise Task.InactiveTask("Cannot stop an inactive task")
362 self.backend.stop_task(self)
364 # Refresh the status again, so that we have updated info stored
365 self.refresh(only_fields=['status', 'start'])
369 raise Task.NotSaved("Task needs to be saved before it can be completed")
371 # Refresh, and raise exception if task is already completed/deleted
372 self.refresh(only_fields=['status'])
375 raise Task.CompletedTask("Cannot complete a completed task")
377 raise Task.DeletedTask("Deleted task cannot be completed")
379 self.backend.complete_task(self)
381 # Refresh the status again, so that we have updated info stored
382 self.refresh(only_fields=['status', 'start', 'end'])
385 if self.saved and not self.modified:
388 # All the actual work is done by the backend
389 self.backend.save_task(self)
391 def add_annotation(self, annotation):
393 raise Task.NotSaved("Task needs to be saved to add annotation")
395 self.backend.annotate_task(self, annotation)
396 self.refresh(only_fields=['annotations'])
398 def remove_annotation(self, annotation):
400 raise Task.NotSaved("Task needs to be saved to remove annotation")
402 if isinstance(annotation, TaskAnnotation):
403 annotation = annotation['description']
405 self.backend.denotate_task(self, annotation)
406 self.refresh(only_fields=['annotations'])
408 def refresh(self, only_fields=None, after_save=False):
409 # Raise error when trying to refresh a task that has not been saved
411 raise Task.NotSaved("Task needs to be saved to be refreshed")
413 new_data = self.backend.refresh_task(self, after_save=after_save)
417 [(k, new_data.get(k)) for k in only_fields])
418 self._update_data(to_update, update_original=True)
420 self._load_data(new_data)
422 class TaskQuerySet(object):
424 Represents a lazy lookup for a task objects.
427 def __init__(self, warrior=None, filter_obj=None):
428 self.warrior = warrior
429 self._result_cache = None
430 self.filter_obj = filter_obj or TaskWarriorFilter(warrior)
432 def __deepcopy__(self, memo):
434 Deep copy of a QuerySet doesn't populate the cache
436 obj = self.__class__()
437 for k, v in self.__dict__.items():
438 if k in ('_iter', '_result_cache'):
439 obj.__dict__[k] = None
441 obj.__dict__[k] = copy.deepcopy(v, memo)
445 data = list(self[:REPR_OUTPUT_SIZE + 1])
446 if len(data) > REPR_OUTPUT_SIZE:
447 data[-1] = "...(remaining elements truncated)..."
451 if self._result_cache is None:
452 self._result_cache = list(self)
453 return len(self._result_cache)
456 if self._result_cache is None:
457 self._result_cache = self._execute()
458 return iter(self._result_cache)
460 def __getitem__(self, k):
461 if self._result_cache is None:
462 self._result_cache = list(self)
463 return self._result_cache.__getitem__(k)
466 if self._result_cache is not None:
467 return bool(self._result_cache)
470 except StopIteration:
474 def __nonzero__(self):
475 return type(self).__bool__(self)
477 def _clone(self, klass=None, **kwargs):
479 klass = self.__class__
480 filter_obj = self.filter_obj.clone()
481 c = klass(warrior=self.warrior, filter_obj=filter_obj)
482 c.__dict__.update(kwargs)
487 Fetch the tasks which match the current filters.
489 return self.warrior.filter_tasks(self.filter_obj)
493 Returns a new TaskQuerySet that is a copy of the current one.
498 return self.filter(status=PENDING)
501 return self.filter(status=COMPLETED)
503 def filter(self, *args, **kwargs):
505 Returns a new TaskQuerySet with the given filters added.
507 clone = self._clone()
509 clone.filter_obj.add_filter(f)
510 for key, value in kwargs.items():
511 clone.filter_obj.add_filter_param(key, value)
514 def get(self, **kwargs):
516 Performs the query and returns a single object matching the given
519 clone = self.filter(**kwargs)
522 return clone._result_cache[0]
524 raise Task.DoesNotExist(
525 'Task matching query does not exist. '
526 'Lookup parameters were {0}'.format(kwargs))
528 'get() returned more than one Task -- it returned {0}! '
529 'Lookup parameters were {1}'.format(num, kwargs))