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'
19 logger = logging.getLogger(__name__)
22 class ReadOnlyDictView(object):
24 Provides simplified read-only view upon dict object.
27 def __init__(self, viewed_dict):
28 self.viewed_dict = viewed_dict
30 def __getitem__(self, key):
31 return copy.deepcopy(self.viewed_dict.__getitem__(key))
33 def __contains__(self, k):
34 return self.viewed_dict.__contains__(k)
37 for value in self.viewed_dict:
38 yield copy.deepcopy(value)
41 return len(self.viewed_dict)
43 def __unicode__(self):
44 return six.u('ReadOnlyDictView: {0}'.format(repr(self.viewed_dict)))
46 __repr__ = __unicode__
48 def get(self, key, default=None):
49 return copy.deepcopy(self.viewed_dict.get(key, default))
52 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
55 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
58 class TaskResource(SerializingObject):
61 def _load_data(self, data):
62 self._data = dict((key, self._deserialize(key, value))
63 for key, value in data.items())
64 # We need to use a copy for original data, so that changes
66 self._original_data = copy.deepcopy(self._data)
68 def _update_data(self, data, update_original=False, remove_missing=False):
70 Low level update of the internal _data dict. Data which are coming as
71 updates should already be serialized. If update_original is True, the
72 original_data dict is updated as well.
74 self._data.update(dict((key, self._deserialize(key, value))
75 for key, value in data.items()))
77 # In certain situations, we want to treat missing keys as removals
79 for key in set(self._data.keys()) - set(data.keys()):
80 self._data[key] = None
83 self._original_data = copy.deepcopy(self._data)
85 def __getitem__(self, key):
86 # This is a workaround to make TaskResource non-iterable
87 # over simple index-based iteration
94 if key not in self._data:
95 self._data[key] = self._deserialize(key, None)
97 return self._data.get(key)
99 def __setitem__(self, key, value):
100 if key in self.read_only_fields:
101 raise RuntimeError('Field \'%s\' is read-only' % key)
103 # Normalize the user input before saving it
104 value = self._normalize(key, value)
105 self._data[key] = value
108 s = six.text_type(self.__unicode__())
110 s = s.encode('utf-8')
116 def export_data(self):
118 Exports current data contained in the Task as JSON
121 # We need to remove spaces for TW-1504, use custom separators
122 data_tuples = ((key, self._serialize(key, value))
123 for key, value in six.iteritems(self._data))
125 # Empty string denotes empty serialized value, we do not want
126 # to pass that to TaskWarrior.
127 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
128 data = dict(data_tuples)
129 return json.dumps(data, separators=(',', ':'))
132 def _modified_fields(self):
133 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
134 for key in writable_fields:
135 new_value = self._data.get(key)
136 old_value = self._original_data.get(key)
138 # Make sure not to mark data removal as modified field if the
139 # field originally had some empty value
140 if key in self._data and not new_value and not old_value:
143 if new_value != old_value:
148 return bool(list(self._modified_fields))
151 class TaskAnnotation(TaskResource):
152 read_only_fields = ['entry', 'description']
154 def __init__(self, task, data=None):
156 self._load_data(data or dict())
157 super(TaskAnnotation, self).__init__(task.backend)
160 self.task.remove_annotation(self)
162 def __unicode__(self):
163 return self['description']
165 def __eq__(self, other):
166 # consider 2 annotations equal if they belong to the same task, and
167 # their data dics are the same
168 return self.task == other.task and self._data == other._data
170 def __ne__(self, other):
171 return not self.__eq__(other)
173 __repr__ = __unicode__
176 class Task(TaskResource):
177 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
179 class DoesNotExist(Exception):
182 class CompletedTask(Exception):
184 Raised when the operation cannot be performed on the completed task.
188 class DeletedTask(Exception):
190 Raised when the operation cannot be performed on the deleted task.
194 class ActiveTask(Exception):
196 Raised when the operation cannot be performed on the active task.
200 class InactiveTask(Exception):
202 Raised when the operation cannot be performed on an inactive task.
206 class NotSaved(Exception):
208 Raised when the operation cannot be performed on the task, because
209 it has not been saved to TaskWarrior yet.
214 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
216 Creates a Task object, directly from the stdin, by reading one line.
217 If modify=True, two lines are used, first line interpreted as the
218 original state of the Task object, and second line as its new,
219 modified value. This is consistent with the TaskWarrior's hook
222 Object created by this method should not be saved, deleted
223 or refreshed, as t could create a infinite loop. For this
224 reason, TaskWarrior instance is set to None.
226 Input_file argument can be used to specify the input file,
227 but defaults to sys.stdin.
230 # Detect the hook type if not given directly
231 name = os.path.basename(sys.argv[0])
232 modify = name.startswith('on-modify') if modify is None else modify
234 # Create the TaskWarrior instance if none passed
236 backends = importlib.import_module('tasklib.backends')
237 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
238 backend = backends.TaskWarrior(data_location=hook_parent_dir)
240 # TaskWarrior instance is set to None
243 # Load the data from the input
244 task._load_data(json.loads(input_file.readline().strip()))
246 # If this is a on-modify event, we are provided with additional
247 # line of input, which provides updated data
249 task._update_data(json.loads(input_file.readline().strip()),
254 def __init__(self, backend, **kwargs):
255 super(Task, self).__init__(backend)
257 # Check that user is not able to set read-only value in __init__
258 for key in kwargs.keys():
259 if key in self.read_only_fields:
260 raise RuntimeError('Field \'%s\' is read-only' % key)
262 # We serialize the data in kwargs so that users of the library
263 # do not have to pass different data formats via __setitem__ and
264 # __init__ methods, that would be confusing
266 # Rather unfortunate syntax due to python2.6 comaptiblity
267 self._data = dict((key, self._normalize(key, value))
268 for (key, value) in six.iteritems(kwargs))
269 self._original_data = copy.deepcopy(self._data)
271 # Provide read only access to the original data
272 self.original = ReadOnlyDictView(self._original_data)
274 def __unicode__(self):
275 return self['description']
277 def __eq__(self, other):
278 if self['uuid'] and other['uuid']:
279 # For saved Tasks, just define equality by equality of uuids
280 return self['uuid'] == other['uuid']
282 # If the tasks are not saved, compare the actual instances
283 return id(self) == id(other)
285 def __ne__(self, other):
286 return not self.__eq__(other)
290 # For saved Tasks, just define equality by equality of uuids
291 return self['uuid'].__hash__()
293 # If the tasks are not saved, return hash of instance id
294 return id(self).__hash__()
298 return self['status'] == six.text_type('completed')
302 return self['status'] == six.text_type('deleted')
306 return self['status'] == six.text_type('waiting')
310 return self['status'] == six.text_type('pending')
314 return self['status'] == six.text_type('recurring')
318 return self['start'] is not None
322 return self['uuid'] is not None or self['id'] is not None
324 def serialize_depends(self, cur_dependencies):
325 # Check that all the tasks are saved
326 for task in (cur_dependencies or set()):
328 raise Task.NotSaved('Task \'%s\' needs to be saved before '
329 'it can be set as dependency.' % task)
331 return super(Task, self).serialize_depends(cur_dependencies)
335 raise Task.NotSaved("Task needs to be saved before it can be deleted")
337 # Refresh the status, and raise exception if the task is deleted
338 self.refresh(only_fields=['status'])
341 raise Task.DeletedTask("Task was already deleted")
343 self.backend.delete_task(self)
345 # Refresh the status again, so that we have updated info stored
346 self.refresh(only_fields=['status', 'start', 'end'])
350 raise Task.NotSaved("Task needs to be saved before it can be started")
352 # Refresh, and raise exception if task is already completed/deleted
353 self.refresh(only_fields=['status'])
356 raise Task.CompletedTask("Cannot start a completed task")
358 raise Task.DeletedTask("Deleted task cannot be started")
360 raise Task.ActiveTask("Task is already active")
362 self.backend.start_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 stopped")
371 # Refresh, and raise exception if task is already completed/deleted
372 self.refresh(only_fields=['status'])
375 raise Task.InactiveTask("Cannot stop an inactive task")
377 self.backend.stop_task(self)
379 # Refresh the status again, so that we have updated info stored
380 self.refresh(only_fields=['status', 'start'])
384 raise Task.NotSaved("Task needs to be saved before it can be completed")
386 # Refresh, and raise exception if task is already completed/deleted
387 self.refresh(only_fields=['status'])
390 raise Task.CompletedTask("Cannot complete a completed task")
392 raise Task.DeletedTask("Deleted task cannot be completed")
394 self.backend.complete_task(self)
396 # Refresh the status again, so that we have updated info stored
397 self.refresh(only_fields=['status', 'start', 'end'])
400 if self.saved and not self.modified:
403 # All the actual work is done by the backend
404 self.backend.save_task(self)
406 def add_annotation(self, annotation):
408 raise Task.NotSaved("Task needs to be saved to add annotation")
410 self.backend.annotate_task(self, annotation)
411 self.refresh(only_fields=['annotations'])
413 def remove_annotation(self, annotation):
415 raise Task.NotSaved("Task needs to be saved to remove annotation")
417 if isinstance(annotation, TaskAnnotation):
418 annotation = annotation['description']
420 self.backend.denotate_task(self, annotation)
421 self.refresh(only_fields=['annotations'])
423 def refresh(self, only_fields=None, after_save=False):
424 # Raise error when trying to refresh a task that has not been saved
426 raise Task.NotSaved("Task needs to be saved to be refreshed")
428 new_data = self.backend.refresh_task(self, after_save=after_save)
432 [(k, new_data.get(k)) for k in only_fields])
433 self._update_data(to_update, update_original=True)
435 self._load_data(new_data)
438 class TaskQuerySet(object):
440 Represents a lazy lookup for a task objects.
443 def __init__(self, backend, filter_obj=None):
444 self.backend = backend
445 self._result_cache = None
446 self.filter_obj = filter_obj or self.backend.filter_class(backend)
448 def __deepcopy__(self, memo):
450 Deep copy of a QuerySet doesn't populate the cache
452 obj = self.__class__(backend=self.backend)
453 for k, v in self.__dict__.items():
454 if k in ('_iter', '_result_cache'):
455 obj.__dict__[k] = None
457 obj.__dict__[k] = copy.deepcopy(v, memo)
461 data = list(self[:REPR_OUTPUT_SIZE + 1])
462 if len(data) > REPR_OUTPUT_SIZE:
463 data[-1] = "...(remaining elements truncated)..."
467 if self._result_cache is None:
468 self._result_cache = list(self)
469 return len(self._result_cache)
472 if self._result_cache is None:
473 self._result_cache = self._execute()
474 return iter(self._result_cache)
476 def __getitem__(self, k):
477 if self._result_cache is None:
478 self._result_cache = list(self)
479 return self._result_cache.__getitem__(k)
482 if self._result_cache is not None:
483 return bool(self._result_cache)
486 except StopIteration:
490 def __nonzero__(self):
491 return type(self).__bool__(self)
493 def _clone(self, klass=None, **kwargs):
495 klass = self.__class__
496 filter_obj = self.filter_obj.clone()
497 c = klass(backend=self.backend, filter_obj=filter_obj)
498 c.__dict__.update(kwargs)
503 Fetch the tasks which match the current filters.
505 return self.backend.filter_tasks(self.filter_obj)
509 Returns a new TaskQuerySet that is a copy of the current one.
514 return self.filter(status=PENDING)
517 return self.filter(status=COMPLETED)
520 return self.filter(status=DELETED)
523 return self.filter(status=WAITING)
525 def filter(self, *args, **kwargs):
527 Returns a new TaskQuerySet with the given filters added.
529 clone = self._clone()
531 clone.filter_obj.add_filter(f)
532 for key, value in kwargs.items():
533 clone.filter_obj.add_filter_param(key, value)
536 def get(self, **kwargs):
538 Performs the query and returns a single object matching the given
541 clone = self.filter(**kwargs)
544 return clone._result_cache[0]
546 raise Task.DoesNotExist(
547 'Task matching query does not exist. '
548 'Lookup parameters were {0}'.format(kwargs))
550 'get() returned more than one Task -- it returned {0}! '
551 'Lookup parameters were {1}'.format(num, kwargs))