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'
17 logger = logging.getLogger(__name__)
20 class ReadOnlyDictView(object):
22 Provides simplified read-only view upon dict object.
25 def __init__(self, viewed_dict):
26 self.viewed_dict = viewed_dict
28 def __getitem__(self, key):
29 return copy.deepcopy(self.viewed_dict.__getitem__(key))
31 def __contains__(self, k):
32 return self.viewed_dict.__contains__(k)
35 for value in self.viewed_dict:
36 yield copy.deepcopy(value)
39 return len(self.viewed_dict)
41 def get(self, key, default=None):
42 return copy.deepcopy(self.viewed_dict.get(key, default))
45 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
48 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
51 class TaskResource(SerializingObject):
54 def _load_data(self, data):
55 self._data = dict((key, self._deserialize(key, value))
56 for key, value in data.items())
57 # We need to use a copy for original data, so that changes
59 self._original_data = copy.deepcopy(self._data)
61 def _update_data(self, data, update_original=False, remove_missing=False):
63 Low level update of the internal _data dict. Data which are coming as
64 updates should already be serialized. If update_original is True, the
65 original_data dict is updated as well.
67 self._data.update(dict((key, self._deserialize(key, value))
68 for key, value in data.items()))
70 # In certain situations, we want to treat missing keys as removals
72 for key in set(self._data.keys()) - set(data.keys()):
73 self._data[key] = None
76 self._original_data = copy.deepcopy(self._data)
78 def __getitem__(self, key):
79 # This is a workaround to make TaskResource non-iterable
80 # over simple index-based iteration
87 if key not in self._data:
88 self._data[key] = self._deserialize(key, None)
90 return self._data.get(key)
92 def __setitem__(self, key, value):
93 if key in self.read_only_fields:
94 raise RuntimeError('Field \'%s\' is read-only' % key)
96 # Normalize the user input before saving it
97 value = self._normalize(key, value)
98 self._data[key] = value
101 s = six.text_type(self.__unicode__())
103 s = s.encode('utf-8')
109 def export_data(self):
111 Exports current data contained in the Task as JSON
114 # We need to remove spaces for TW-1504, use custom separators
115 data_tuples = ((key, self._serialize(key, value))
116 for key, value in six.iteritems(self._data))
118 # Empty string denotes empty serialized value, we do not want
119 # to pass that to TaskWarrior.
120 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
121 data = dict(data_tuples)
122 return json.dumps(data, separators=(',', ':'))
125 def _modified_fields(self):
126 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
127 for key in writable_fields:
128 new_value = self._data.get(key)
129 old_value = self._original_data.get(key)
131 # Make sure not to mark data removal as modified field if the
132 # field originally had some empty value
133 if key in self._data and not new_value and not old_value:
136 if new_value != old_value:
141 return bool(list(self._modified_fields))
144 class TaskAnnotation(TaskResource):
145 read_only_fields = ['entry', 'description']
147 def __init__(self, task, data=None):
149 self._load_data(data or dict())
150 super(TaskAnnotation, self).__init__(task.backend)
153 self.task.remove_annotation(self)
155 def __unicode__(self):
156 return self['description']
158 def __eq__(self, other):
159 # consider 2 annotations equal if they belong to the same task, and
160 # their data dics are the same
161 return self.task == other.task and self._data == other._data
163 __repr__ = __unicode__
166 class Task(TaskResource):
167 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
169 class DoesNotExist(Exception):
172 class CompletedTask(Exception):
174 Raised when the operation cannot be performed on the completed task.
178 class DeletedTask(Exception):
180 Raised when the operation cannot be performed on the deleted task.
184 class ActiveTask(Exception):
186 Raised when the operation cannot be performed on the active task.
190 class InactiveTask(Exception):
192 Raised when the operation cannot be performed on an inactive task.
196 class NotSaved(Exception):
198 Raised when the operation cannot be performed on the task, because
199 it has not been saved to TaskWarrior yet.
204 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
206 Creates a Task object, directly from the stdin, by reading one line.
207 If modify=True, two lines are used, first line interpreted as the
208 original state of the Task object, and second line as its new,
209 modified value. This is consistent with the TaskWarrior's hook
212 Object created by this method should not be saved, deleted
213 or refreshed, as t could create a infinite loop. For this
214 reason, TaskWarrior instance is set to None.
216 Input_file argument can be used to specify the input file,
217 but defaults to sys.stdin.
220 # Detect the hook type if not given directly
221 name = os.path.basename(sys.argv[0])
222 modify = name.startswith('on-modify') if modify is None else modify
224 # Create the TaskWarrior instance if none passed
226 backends = importlib.import_module('tasklib.backends')
227 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
228 backend = backends.TaskWarrior(data_location=hook_parent_dir)
230 # TaskWarrior instance is set to None
233 # Load the data from the input
234 task._load_data(json.loads(input_file.readline().strip()))
236 # If this is a on-modify event, we are provided with additional
237 # line of input, which provides updated data
239 task._update_data(json.loads(input_file.readline().strip()),
244 def __init__(self, backend, **kwargs):
245 super(Task, self).__init__(backend)
247 # Check that user is not able to set read-only value in __init__
248 for key in kwargs.keys():
249 if key in self.read_only_fields:
250 raise RuntimeError('Field \'%s\' is read-only' % key)
252 # We serialize the data in kwargs so that users of the library
253 # do not have to pass different data formats via __setitem__ and
254 # __init__ methods, that would be confusing
256 # Rather unfortunate syntax due to python2.6 comaptiblity
257 self._data = dict((key, self._normalize(key, value))
258 for (key, value) in six.iteritems(kwargs))
259 self._original_data = copy.deepcopy(self._data)
261 # Provide read only access to the original data
262 self.original = ReadOnlyDictView(self._original_data)
264 def __unicode__(self):
265 return self['description']
267 def __eq__(self, other):
268 if self['uuid'] and other['uuid']:
269 # For saved Tasks, just define equality by equality of uuids
270 return self['uuid'] == other['uuid']
272 # If the tasks are not saved, compare the actual instances
273 return id(self) == id(other)
277 # For saved Tasks, just define equality by equality of uuids
278 return self['uuid'].__hash__()
280 # If the tasks are not saved, return hash of instance id
281 return id(self).__hash__()
285 return self['status'] == six.text_type('completed')
289 return self['status'] == six.text_type('deleted')
293 return self['status'] == six.text_type('waiting')
297 return self['status'] == six.text_type('pending')
301 return self['start'] is not None
305 return self['uuid'] is not None or self['id'] is not None
307 def serialize_depends(self, cur_dependencies):
308 # Check that all the tasks are saved
309 for task in (cur_dependencies or set()):
311 raise Task.NotSaved('Task \'%s\' needs to be saved before '
312 'it can be set as dependency.' % task)
314 return super(Task, self).serialize_depends(cur_dependencies)
318 raise Task.NotSaved("Task needs to be saved before it can be deleted")
320 # Refresh the status, and raise exception if the task is deleted
321 self.refresh(only_fields=['status'])
324 raise Task.DeletedTask("Task was already deleted")
326 self.backend.delete_task(self)
328 # Refresh the status again, so that we have updated info stored
329 self.refresh(only_fields=['status', 'start', 'end'])
333 raise Task.NotSaved("Task needs to be saved before it can be started")
335 # Refresh, and raise exception if task is already completed/deleted
336 self.refresh(only_fields=['status'])
339 raise Task.CompletedTask("Cannot start a completed task")
341 raise Task.DeletedTask("Deleted task cannot be started")
343 raise Task.ActiveTask("Task is already active")
345 self.backend.start_task(self)
347 # Refresh the status again, so that we have updated info stored
348 self.refresh(only_fields=['status', 'start'])
352 raise Task.NotSaved("Task needs to be saved before it can be stopped")
354 # Refresh, and raise exception if task is already completed/deleted
355 self.refresh(only_fields=['status'])
358 raise Task.InactiveTask("Cannot stop an inactive task")
360 self.backend.stop_task(self)
362 # Refresh the status again, so that we have updated info stored
363 self.refresh(only_fields=['status', 'start'])
367 raise Task.NotSaved("Task needs to be saved before it can be completed")
369 # Refresh, and raise exception if task is already completed/deleted
370 self.refresh(only_fields=['status'])
373 raise Task.CompletedTask("Cannot complete a completed task")
375 raise Task.DeletedTask("Deleted task cannot be completed")
377 self.backend.complete_task(self)
379 # Refresh the status again, so that we have updated info stored
380 self.refresh(only_fields=['status', 'start', 'end'])
383 if self.saved and not self.modified:
386 # All the actual work is done by the backend
387 self.backend.save_task(self)
389 def add_annotation(self, annotation):
391 raise Task.NotSaved("Task needs to be saved to add annotation")
393 self.backend.annotate_task(self, annotation)
394 self.refresh(only_fields=['annotations'])
396 def remove_annotation(self, annotation):
398 raise Task.NotSaved("Task needs to be saved to remove annotation")
400 if isinstance(annotation, TaskAnnotation):
401 annotation = annotation['description']
403 self.backend.denotate_task(self, annotation)
404 self.refresh(only_fields=['annotations'])
406 def refresh(self, only_fields=None, after_save=False):
407 # Raise error when trying to refresh a task that has not been saved
409 raise Task.NotSaved("Task needs to be saved to be refreshed")
411 new_data = self.backend.refresh_task(self, after_save=after_save)
415 [(k, new_data.get(k)) for k in only_fields])
416 self._update_data(to_update, update_original=True)
418 self._load_data(new_data)
421 class TaskQuerySet(object):
423 Represents a lazy lookup for a task objects.
426 def __init__(self, backend, filter_obj=None):
427 self.backend = backend
428 self._result_cache = None
429 self.filter_obj = filter_obj or self.backend.filter_class(backend)
431 def __deepcopy__(self, memo):
433 Deep copy of a QuerySet doesn't populate the cache
435 obj = self.__class__(backend=self.backend)
436 for k, v in self.__dict__.items():
437 if k in ('_iter', '_result_cache'):
438 obj.__dict__[k] = None
440 obj.__dict__[k] = copy.deepcopy(v, memo)
444 data = list(self[:REPR_OUTPUT_SIZE + 1])
445 if len(data) > REPR_OUTPUT_SIZE:
446 data[-1] = "...(remaining elements truncated)..."
450 if self._result_cache is None:
451 self._result_cache = list(self)
452 return len(self._result_cache)
455 if self._result_cache is None:
456 self._result_cache = self._execute()
457 return iter(self._result_cache)
459 def __getitem__(self, k):
460 if self._result_cache is None:
461 self._result_cache = list(self)
462 return self._result_cache.__getitem__(k)
465 if self._result_cache is not None:
466 return bool(self._result_cache)
469 except StopIteration:
473 def __nonzero__(self):
474 return type(self).__bool__(self)
476 def _clone(self, klass=None, **kwargs):
478 klass = self.__class__
479 filter_obj = self.filter_obj.clone()
480 c = klass(backend=self.backend, filter_obj=filter_obj)
481 c.__dict__.update(kwargs)
486 Fetch the tasks which match the current filters.
488 return self.backend.filter_tasks(self.filter_obj)
492 Returns a new TaskQuerySet that is a copy of the current one.
497 return self.filter(status=PENDING)
500 return self.filter(status=COMPLETED)
502 def filter(self, *args, **kwargs):
504 Returns a new TaskQuerySet with the given filters added.
506 clone = self._clone()
508 clone.filter_obj.add_filter(f)
509 for key, value in kwargs.items():
510 clone.filter_obj.add_filter_param(key, value)
513 def get(self, **kwargs):
515 Performs the query and returns a single object matching the given
518 clone = self.filter(**kwargs)
521 return clone._result_cache[0]
523 raise Task.DoesNotExist(
524 'Task matching query does not exist. '
525 'Lookup parameters were {0}'.format(kwargs))
527 'get() returned more than one Task -- it returned {0}! '
528 'Lookup parameters were {1}'.format(num, kwargs))