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 __unicode__(self):
42 return six.u('ReadOnlyDictView: {0}'.format(repr(self.viewed_dict)))
44 __repr__ = __unicode__
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)
83 def __getitem__(self, key):
84 # This is a workaround to make TaskResource non-iterable
85 # over simple index-based iteration
92 if key not in self._data:
93 self._data[key] = self._deserialize(key, None)
95 return self._data.get(key)
97 def __setitem__(self, key, value):
98 if key in self.read_only_fields:
99 raise RuntimeError('Field \'%s\' is read-only' % key)
101 # Normalize the user input before saving it
102 value = self._normalize(key, value)
103 self._data[key] = value
106 s = six.text_type(self.__unicode__())
108 s = s.encode('utf-8')
114 def export_data(self):
116 Exports current data contained in the Task as JSON
119 # We need to remove spaces for TW-1504, use custom separators
120 data_tuples = ((key, self._serialize(key, value))
121 for key, value in six.iteritems(self._data))
123 # Empty string denotes empty serialized value, we do not want
124 # to pass that to TaskWarrior.
125 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
126 data = dict(data_tuples)
127 return json.dumps(data, separators=(',', ':'))
130 def _modified_fields(self):
131 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
132 for key in writable_fields:
133 new_value = self._data.get(key)
134 old_value = self._original_data.get(key)
136 # Make sure not to mark data removal as modified field if the
137 # field originally had some empty value
138 if key in self._data and not new_value and not old_value:
141 if new_value != old_value:
146 return bool(list(self._modified_fields))
149 class TaskAnnotation(TaskResource):
150 read_only_fields = ['entry', 'description']
152 def __init__(self, task, data=None):
154 self._load_data(data or dict())
155 super(TaskAnnotation, self).__init__(task.backend)
158 self.task.remove_annotation(self)
160 def __unicode__(self):
161 return self['description']
163 def __eq__(self, other):
164 # consider 2 annotations equal if they belong to the same task, and
165 # their data dics are the same
166 return self.task == other.task and self._data == other._data
168 def __ne__(self, other):
169 return not self.__eq__(other)
171 __repr__ = __unicode__
174 class Task(TaskResource):
175 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
177 class DoesNotExist(Exception):
180 class CompletedTask(Exception):
182 Raised when the operation cannot be performed on the completed task.
186 class DeletedTask(Exception):
188 Raised when the operation cannot be performed on the deleted task.
192 class ActiveTask(Exception):
194 Raised when the operation cannot be performed on the active task.
198 class InactiveTask(Exception):
200 Raised when the operation cannot be performed on an inactive task.
204 class NotSaved(Exception):
206 Raised when the operation cannot be performed on the task, because
207 it has not been saved to TaskWarrior yet.
212 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
214 Creates a Task object, directly from the stdin, by reading one line.
215 If modify=True, two lines are used, first line interpreted as the
216 original state of the Task object, and second line as its new,
217 modified value. This is consistent with the TaskWarrior's hook
220 Object created by this method should not be saved, deleted
221 or refreshed, as t could create a infinite loop. For this
222 reason, TaskWarrior instance is set to None.
224 Input_file argument can be used to specify the input file,
225 but defaults to sys.stdin.
228 # Detect the hook type if not given directly
229 name = os.path.basename(sys.argv[0])
230 modify = name.startswith('on-modify') if modify is None else modify
232 # Create the TaskWarrior instance if none passed
234 backends = importlib.import_module('tasklib.backends')
235 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
236 backend = backends.TaskWarrior(data_location=hook_parent_dir)
238 # TaskWarrior instance is set to None
241 # Load the data from the input
242 task._load_data(json.loads(input_file.readline().strip()))
244 # If this is a on-modify event, we are provided with additional
245 # line of input, which provides updated data
247 task._update_data(json.loads(input_file.readline().strip()),
252 def __init__(self, backend, **kwargs):
253 super(Task, self).__init__(backend)
255 # Check that user is not able to set read-only value in __init__
256 for key in kwargs.keys():
257 if key in self.read_only_fields:
258 raise RuntimeError('Field \'%s\' is read-only' % key)
260 # We serialize the data in kwargs so that users of the library
261 # do not have to pass different data formats via __setitem__ and
262 # __init__ methods, that would be confusing
264 # Rather unfortunate syntax due to python2.6 comaptiblity
265 self._data = dict((key, self._normalize(key, value))
266 for (key, value) in six.iteritems(kwargs))
267 self._original_data = copy.deepcopy(self._data)
269 # Provide read only access to the original data
270 self.original = ReadOnlyDictView(self._original_data)
272 def __unicode__(self):
273 return self['description']
275 def __eq__(self, other):
276 if self['uuid'] and other['uuid']:
277 # For saved Tasks, just define equality by equality of uuids
278 return self['uuid'] == other['uuid']
280 # If the tasks are not saved, compare the actual instances
281 return id(self) == id(other)
283 def __ne__(self, other):
284 return not self.__eq__(other)
288 # For saved Tasks, just define equality by equality of uuids
289 return self['uuid'].__hash__()
291 # If the tasks are not saved, return hash of instance id
292 return id(self).__hash__()
296 return self['status'] == six.text_type('completed')
300 return self['status'] == six.text_type('deleted')
304 return self['status'] == six.text_type('waiting')
308 return self['status'] == six.text_type('pending')
312 return self['start'] is not None
316 return self['uuid'] is not None or self['id'] is not None
318 def serialize_depends(self, cur_dependencies):
319 # Check that all the tasks are saved
320 for task in (cur_dependencies or set()):
322 raise Task.NotSaved('Task \'%s\' needs to be saved before '
323 'it can be set as dependency.' % task)
325 return super(Task, self).serialize_depends(cur_dependencies)
329 raise Task.NotSaved("Task needs to be saved before it can be deleted")
331 # Refresh the status, and raise exception if the task is deleted
332 self.refresh(only_fields=['status'])
335 raise Task.DeletedTask("Task was already deleted")
337 self.backend.delete_task(self)
339 # Refresh the status again, so that we have updated info stored
340 self.refresh(only_fields=['status', 'start', 'end'])
344 raise Task.NotSaved("Task needs to be saved before it can be started")
346 # Refresh, and raise exception if task is already completed/deleted
347 self.refresh(only_fields=['status'])
350 raise Task.CompletedTask("Cannot start a completed task")
352 raise Task.DeletedTask("Deleted task cannot be started")
354 raise Task.ActiveTask("Task is already active")
356 self.backend.start_task(self)
358 # Refresh the status again, so that we have updated info stored
359 self.refresh(only_fields=['status', 'start'])
363 raise Task.NotSaved("Task needs to be saved before it can be stopped")
365 # Refresh, and raise exception if task is already completed/deleted
366 self.refresh(only_fields=['status'])
369 raise Task.InactiveTask("Cannot stop an inactive task")
371 self.backend.stop_task(self)
373 # Refresh the status again, so that we have updated info stored
374 self.refresh(only_fields=['status', 'start'])
378 raise Task.NotSaved("Task needs to be saved before it can be completed")
380 # Refresh, and raise exception if task is already completed/deleted
381 self.refresh(only_fields=['status'])
384 raise Task.CompletedTask("Cannot complete a completed task")
386 raise Task.DeletedTask("Deleted task cannot be completed")
388 self.backend.complete_task(self)
390 # Refresh the status again, so that we have updated info stored
391 self.refresh(only_fields=['status', 'start', 'end'])
394 if self.saved and not self.modified:
397 # All the actual work is done by the backend
398 self.backend.save_task(self)
400 def add_annotation(self, annotation):
402 raise Task.NotSaved("Task needs to be saved to add annotation")
404 self.backend.annotate_task(self, annotation)
405 self.refresh(only_fields=['annotations'])
407 def remove_annotation(self, annotation):
409 raise Task.NotSaved("Task needs to be saved to remove annotation")
411 if isinstance(annotation, TaskAnnotation):
412 annotation = annotation['description']
414 self.backend.denotate_task(self, annotation)
415 self.refresh(only_fields=['annotations'])
417 def refresh(self, only_fields=None, after_save=False):
418 # Raise error when trying to refresh a task that has not been saved
420 raise Task.NotSaved("Task needs to be saved to be refreshed")
422 new_data = self.backend.refresh_task(self, after_save=after_save)
426 [(k, new_data.get(k)) for k in only_fields])
427 self._update_data(to_update, update_original=True)
429 self._load_data(new_data)
432 class TaskQuerySet(object):
434 Represents a lazy lookup for a task objects.
437 def __init__(self, backend, filter_obj=None):
438 self.backend = backend
439 self._result_cache = None
440 self.filter_obj = filter_obj or self.backend.filter_class(backend)
442 def __deepcopy__(self, memo):
444 Deep copy of a QuerySet doesn't populate the cache
446 obj = self.__class__(backend=self.backend)
447 for k, v in self.__dict__.items():
448 if k in ('_iter', '_result_cache'):
449 obj.__dict__[k] = None
451 obj.__dict__[k] = copy.deepcopy(v, memo)
455 data = list(self[:REPR_OUTPUT_SIZE + 1])
456 if len(data) > REPR_OUTPUT_SIZE:
457 data[-1] = "...(remaining elements truncated)..."
461 if self._result_cache is None:
462 self._result_cache = list(self)
463 return len(self._result_cache)
466 if self._result_cache is None:
467 self._result_cache = self._execute()
468 return iter(self._result_cache)
470 def __getitem__(self, k):
471 if self._result_cache is None:
472 self._result_cache = list(self)
473 return self._result_cache.__getitem__(k)
476 if self._result_cache is not None:
477 return bool(self._result_cache)
480 except StopIteration:
484 def __nonzero__(self):
485 return type(self).__bool__(self)
487 def _clone(self, klass=None, **kwargs):
489 klass = self.__class__
490 filter_obj = self.filter_obj.clone()
491 c = klass(backend=self.backend, filter_obj=filter_obj)
492 c.__dict__.update(kwargs)
497 Fetch the tasks which match the current filters.
499 return self.backend.filter_tasks(self.filter_obj)
503 Returns a new TaskQuerySet that is a copy of the current one.
508 return self.filter(status=PENDING)
511 return self.filter(status=COMPLETED)
513 def filter(self, *args, **kwargs):
515 Returns a new TaskQuerySet with the given filters added.
517 clone = self._clone()
519 clone.filter_obj.add_filter(f)
520 for key, value in kwargs.items():
521 clone.filter_obj.add_filter_param(key, value)
524 def get(self, **kwargs):
526 Performs the query and returns a single object matching the given
529 clone = self.filter(**kwargs)
532 return clone._result_cache[0]
534 raise Task.DoesNotExist(
535 'Task matching query does not exist. '
536 'Lookup parameters were {0}'.format(kwargs))
538 'get() returned more than one Task -- it returned {0}! '
539 'Lookup parameters were {1}'.format(num, kwargs))