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
12 from backends import TaskWarrior, TaskWarriorException
13 from serializing import SerializingObject
15 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
16 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
19 COMPLETED = 'completed'
21 logger = logging.getLogger(__name__)
22 local_zone = tzlocal.get_localzone()
25 class ReadOnlyDictView(object):
27 Provides simplified read-only view upon dict object.
30 def __init__(self, viewed_dict):
31 self.viewed_dict = viewed_dict
33 def __getitem__(self, key):
34 return copy.deepcopy(self.viewed_dict.__getitem__(key))
36 def __contains__(self, k):
37 return self.viewed_dict.__contains__(k)
40 for value in self.viewed_dict:
41 yield copy.deepcopy(value)
44 return len(self.viewed_dict)
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)
84 def __getitem__(self, key):
85 # This is a workaround to make TaskResource non-iterable
86 # over simple index-based iteration
93 if key not in self._data:
94 self._data[key] = self._deserialize(key, None)
96 return self._data.get(key)
98 def __setitem__(self, key, value):
99 if key in self.read_only_fields:
100 raise RuntimeError('Field \'%s\' is read-only' % key)
102 # Normalize the user input before saving it
103 value = self._normalize(key, value)
104 self._data[key] = value
107 s = six.text_type(self.__unicode__())
109 s = s.encode('utf-8')
115 def export_data(self):
117 Exports current data contained in the Task as JSON
120 # We need to remove spaces for TW-1504, use custom separators
121 data_tuples = ((key, self._serialize(key, value))
122 for key, value in six.iteritems(self._data))
124 # Empty string denotes empty serialized value, we do not want
125 # to pass that to TaskWarrior.
126 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
127 data = dict(data_tuples)
128 return json.dumps(data, separators=(',',':'))
131 def _modified_fields(self):
132 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
133 for key in writable_fields:
134 new_value = self._data.get(key)
135 old_value = self._original_data.get(key)
137 # Make sure not to mark data removal as modified field if the
138 # field originally had some empty value
139 if key in self._data and not new_value and not old_value:
142 if new_value != old_value:
147 return bool(list(self._modified_fields))
150 class TaskAnnotation(TaskResource):
151 read_only_fields = ['entry', 'description']
153 def __init__(self, task, data=None):
155 self._load_data(data or dict())
156 super(TaskAnnotation, self).__init__(task.warrior)
159 self.task.remove_annotation(self)
161 def __unicode__(self):
162 return self['description']
164 def __eq__(self, other):
165 # consider 2 annotations equal if they belong to the same task, and
166 # their data dics are the same
167 return self.task == other.task and self._data == other._data
169 __repr__ = __unicode__
172 class Task(TaskResource):
173 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
175 class DoesNotExist(Exception):
178 class CompletedTask(Exception):
180 Raised when the operation cannot be performed on the completed task.
184 class DeletedTask(Exception):
186 Raised when the operation cannot be performed on the deleted task.
190 class ActiveTask(Exception):
192 Raised when the operation cannot be performed on the active task.
196 class InactiveTask(Exception):
198 Raised when the operation cannot be performed on an inactive task.
202 class NotSaved(Exception):
204 Raised when the operation cannot be performed on the task, because
205 it has not been saved to TaskWarrior yet.
210 def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
212 Creates a Task object, directly from the stdin, by reading one line.
213 If modify=True, two lines are used, first line interpreted as the
214 original state of the Task object, and second line as its new,
215 modified value. This is consistent with the TaskWarrior's hook
218 Object created by this method should not be saved, deleted
219 or refreshed, as t could create a infinite loop. For this
220 reason, TaskWarrior instance is set to None.
222 Input_file argument can be used to specify the input file,
223 but defaults to sys.stdin.
226 # Detect the hook type if not given directly
227 name = os.path.basename(sys.argv[0])
228 modify = name.startswith('on-modify') if modify is None else modify
230 # Create the TaskWarrior instance if none passed
232 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
233 warrior = TaskWarrior(data_location=hook_parent_dir)
235 # TaskWarrior instance is set to None
238 # Load the data from the input
239 task._load_data(json.loads(input_file.readline().strip()))
241 # If this is a on-modify event, we are provided with additional
242 # line of input, which provides updated data
244 task._update_data(json.loads(input_file.readline().strip()),
249 def __init__(self, warrior, **kwargs):
250 super(Task, self).__init__(warrior)
252 # Check that user is not able to set read-only value in __init__
253 for key in kwargs.keys():
254 if key in self.read_only_fields:
255 raise RuntimeError('Field \'%s\' is read-only' % key)
257 # We serialize the data in kwargs so that users of the library
258 # do not have to pass different data formats via __setitem__ and
259 # __init__ methods, that would be confusing
261 # Rather unfortunate syntax due to python2.6 comaptiblity
262 self._data = dict((key, self._normalize(key, value))
263 for (key, value) in six.iteritems(kwargs))
264 self._original_data = copy.deepcopy(self._data)
266 # Provide read only access to the original data
267 self.original = ReadOnlyDictView(self._original_data)
269 def __unicode__(self):
270 return self['description']
272 def __eq__(self, other):
273 if self['uuid'] and other['uuid']:
274 # For saved Tasks, just define equality by equality of uuids
275 return self['uuid'] == other['uuid']
277 # If the tasks are not saved, compare the actual instances
278 return id(self) == id(other)
283 # For saved Tasks, just define equality by equality of uuids
284 return self['uuid'].__hash__()
286 # If the tasks are not saved, return hash of instance id
287 return id(self).__hash__()
291 return self['status'] == six.text_type('completed')
295 return self['status'] == six.text_type('deleted')
299 return self['status'] == six.text_type('waiting')
303 return self['status'] == six.text_type('pending')
307 return self['start'] is not None
311 return self['uuid'] is not None or self['id'] is not None
313 def serialize_depends(self, cur_dependencies):
314 # Check that all the tasks are saved
315 for task in (cur_dependencies or set()):
317 raise Task.NotSaved('Task \'%s\' needs to be saved before '
318 'it can be set as dependency.' % task)
320 return super(Task, self).serialize_depends(cur_dependencies)
324 raise Task.NotSaved("Task needs to be saved before it can be deleted")
326 # Refresh the status, and raise exception if the task is deleted
327 self.refresh(only_fields=['status'])
330 raise Task.DeletedTask("Task was already deleted")
332 self.backend.delete_task(self)
334 # Refresh the status again, so that we have updated info stored
335 self.refresh(only_fields=['status', 'start', 'end'])
339 raise Task.NotSaved("Task needs to be saved before it can be started")
341 # Refresh, and raise exception if task is already completed/deleted
342 self.refresh(only_fields=['status'])
345 raise Task.CompletedTask("Cannot start a completed task")
347 raise Task.DeletedTask("Deleted task cannot be started")
349 raise Task.ActiveTask("Task is already active")
351 self.backend.start_task(self)
353 # Refresh the status again, so that we have updated info stored
354 self.refresh(only_fields=['status', 'start'])
358 raise Task.NotSaved("Task needs to be saved before it can be stopped")
360 # Refresh, and raise exception if task is already completed/deleted
361 self.refresh(only_fields=['status'])
364 raise Task.InactiveTask("Cannot stop an inactive task")
366 self.backend.stop_task(self)
368 # Refresh the status again, so that we have updated info stored
369 self.refresh(only_fields=['status', 'start'])
373 raise Task.NotSaved("Task needs to be saved before it can be completed")
375 # Refresh, and raise exception if task is already completed/deleted
376 self.refresh(only_fields=['status'])
379 raise Task.CompletedTask("Cannot complete a completed task")
381 raise Task.DeletedTask("Deleted task cannot be completed")
383 self.backend.complete_task(self)
385 # Refresh the status again, so that we have updated info stored
386 self.refresh(only_fields=['status', 'start', 'end'])
389 if self.saved and not self.modified:
392 # All the actual work is done by the backend
393 self.backend.save_task(self)
395 def add_annotation(self, annotation):
397 raise Task.NotSaved("Task needs to be saved to add annotation")
399 self.backend.annotate_task(self, annotation)
400 self.refresh(only_fields=['annotations'])
402 def remove_annotation(self, annotation):
404 raise Task.NotSaved("Task needs to be saved to remove annotation")
406 if isinstance(annotation, TaskAnnotation):
407 annotation = annotation['description']
409 self.backend.denotate_task(self, annotation)
410 self.refresh(only_fields=['annotations'])
412 def refresh(self, only_fields=None, after_save=False):
413 # Raise error when trying to refresh a task that has not been saved
415 raise Task.NotSaved("Task needs to be saved to be refreshed")
417 new_data = self.backend.refresh_task(self, after_save=after_save)
421 [(k, new_data.get(k)) for k in only_fields])
422 self._update_data(to_update, update_original=True)
424 self._load_data(new_data)
426 class TaskQuerySet(object):
428 Represents a lazy lookup for a task objects.
431 def __init__(self, warrior=None, filter_obj=None):
432 self.warrior = warrior
433 self._result_cache = None
434 self.filter_obj = filter_obj or TaskWarriorFilter(warrior)
436 def __deepcopy__(self, memo):
438 Deep copy of a QuerySet doesn't populate the cache
440 obj = self.__class__()
441 for k, v in self.__dict__.items():
442 if k in ('_iter', '_result_cache'):
443 obj.__dict__[k] = None
445 obj.__dict__[k] = copy.deepcopy(v, memo)
449 data = list(self[:REPR_OUTPUT_SIZE + 1])
450 if len(data) > REPR_OUTPUT_SIZE:
451 data[-1] = "...(remaining elements truncated)..."
455 if self._result_cache is None:
456 self._result_cache = list(self)
457 return len(self._result_cache)
460 if self._result_cache is None:
461 self._result_cache = self._execute()
462 return iter(self._result_cache)
464 def __getitem__(self, k):
465 if self._result_cache is None:
466 self._result_cache = list(self)
467 return self._result_cache.__getitem__(k)
470 if self._result_cache is not None:
471 return bool(self._result_cache)
474 except StopIteration:
478 def __nonzero__(self):
479 return type(self).__bool__(self)
481 def _clone(self, klass=None, **kwargs):
483 klass = self.__class__
484 filter_obj = self.filter_obj.clone()
485 c = klass(warrior=self.warrior, filter_obj=filter_obj)
486 c.__dict__.update(kwargs)
491 Fetch the tasks which match the current filters.
493 return self.warrior.filter_tasks(self.filter_obj)
497 Returns a new TaskQuerySet that is a copy of the current one.
502 return self.filter(status=PENDING)
505 return self.filter(status=COMPLETED)
507 def filter(self, *args, **kwargs):
509 Returns a new TaskQuerySet with the given filters added.
511 clone = self._clone()
513 clone.filter_obj.add_filter(f)
514 for key, value in kwargs.items():
515 clone.filter_obj.add_filter_param(key, value)
518 def get(self, **kwargs):
520 Performs the query and returns a single object matching the given
523 clone = self.filter(**kwargs)
526 return clone._result_cache[0]
528 raise Task.DoesNotExist(
529 'Task matching query does not exist. '
530 'Lookup parameters were {0}'.format(kwargs))
532 'get() returned more than one Task -- it returned {0}! '
533 'Lookup parameters were {1}'.format(num, kwargs))