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['start'] is not None
318 return self['uuid'] is not None or self['id'] is not None
320 def serialize_depends(self, cur_dependencies):
321 # Check that all the tasks are saved
322 for task in (cur_dependencies or set()):
324 raise Task.NotSaved('Task \'%s\' needs to be saved before '
325 'it can be set as dependency.' % task)
327 return super(Task, self).serialize_depends(cur_dependencies)
331 raise Task.NotSaved("Task needs to be saved before it can be deleted")
333 # Refresh the status, and raise exception if the task is deleted
334 self.refresh(only_fields=['status'])
337 raise Task.DeletedTask("Task was already deleted")
339 self.backend.delete_task(self)
341 # Refresh the status again, so that we have updated info stored
342 self.refresh(only_fields=['status', 'start', 'end'])
346 raise Task.NotSaved("Task needs to be saved before it can be started")
348 # Refresh, and raise exception if task is already completed/deleted
349 self.refresh(only_fields=['status'])
352 raise Task.CompletedTask("Cannot start a completed task")
354 raise Task.DeletedTask("Deleted task cannot be started")
356 raise Task.ActiveTask("Task is already active")
358 self.backend.start_task(self)
360 # Refresh the status again, so that we have updated info stored
361 self.refresh(only_fields=['status', 'start'])
365 raise Task.NotSaved("Task needs to be saved before it can be stopped")
367 # Refresh, and raise exception if task is already completed/deleted
368 self.refresh(only_fields=['status'])
371 raise Task.InactiveTask("Cannot stop an inactive task")
373 self.backend.stop_task(self)
375 # Refresh the status again, so that we have updated info stored
376 self.refresh(only_fields=['status', 'start'])
380 raise Task.NotSaved("Task needs to be saved before it can be completed")
382 # Refresh, and raise exception if task is already completed/deleted
383 self.refresh(only_fields=['status'])
386 raise Task.CompletedTask("Cannot complete a completed task")
388 raise Task.DeletedTask("Deleted task cannot be completed")
390 self.backend.complete_task(self)
392 # Refresh the status again, so that we have updated info stored
393 self.refresh(only_fields=['status', 'start', 'end'])
396 if self.saved and not self.modified:
399 # All the actual work is done by the backend
400 self.backend.save_task(self)
402 def add_annotation(self, annotation):
404 raise Task.NotSaved("Task needs to be saved to add annotation")
406 self.backend.annotate_task(self, annotation)
407 self.refresh(only_fields=['annotations'])
409 def remove_annotation(self, annotation):
411 raise Task.NotSaved("Task needs to be saved to remove annotation")
413 if isinstance(annotation, TaskAnnotation):
414 annotation = annotation['description']
416 self.backend.denotate_task(self, annotation)
417 self.refresh(only_fields=['annotations'])
419 def refresh(self, only_fields=None, after_save=False):
420 # Raise error when trying to refresh a task that has not been saved
422 raise Task.NotSaved("Task needs to be saved to be refreshed")
424 new_data = self.backend.refresh_task(self, after_save=after_save)
428 [(k, new_data.get(k)) for k in only_fields])
429 self._update_data(to_update, update_original=True)
431 self._load_data(new_data)
434 class TaskQuerySet(object):
436 Represents a lazy lookup for a task objects.
439 def __init__(self, backend, filter_obj=None):
440 self.backend = backend
441 self._result_cache = None
442 self.filter_obj = filter_obj or self.backend.filter_class(backend)
444 def __deepcopy__(self, memo):
446 Deep copy of a QuerySet doesn't populate the cache
448 obj = self.__class__(backend=self.backend)
449 for k, v in self.__dict__.items():
450 if k in ('_iter', '_result_cache'):
451 obj.__dict__[k] = None
453 obj.__dict__[k] = copy.deepcopy(v, memo)
457 data = list(self[:REPR_OUTPUT_SIZE + 1])
458 if len(data) > REPR_OUTPUT_SIZE:
459 data[-1] = "...(remaining elements truncated)..."
463 if self._result_cache is None:
464 self._result_cache = list(self)
465 return len(self._result_cache)
468 if self._result_cache is None:
469 self._result_cache = self._execute()
470 return iter(self._result_cache)
472 def __getitem__(self, k):
473 if self._result_cache is None:
474 self._result_cache = list(self)
475 return self._result_cache.__getitem__(k)
478 if self._result_cache is not None:
479 return bool(self._result_cache)
482 except StopIteration:
486 def __nonzero__(self):
487 return type(self).__bool__(self)
489 def _clone(self, klass=None, **kwargs):
491 klass = self.__class__
492 filter_obj = self.filter_obj.clone()
493 c = klass(backend=self.backend, filter_obj=filter_obj)
494 c.__dict__.update(kwargs)
499 Fetch the tasks which match the current filters.
501 return self.backend.filter_tasks(self.filter_obj)
505 Returns a new TaskQuerySet that is a copy of the current one.
510 return self.filter(status=PENDING)
513 return self.filter(status=COMPLETED)
516 return self.filter(status=DELETED)
519 return self.filter(status=WAITING)
521 def filter(self, *args, **kwargs):
523 Returns a new TaskQuerySet with the given filters added.
525 clone = self._clone()
527 clone.filter_obj.add_filter(f)
528 for key, value in kwargs.items():
529 clone.filter_obj.add_filter_param(key, value)
532 def get(self, **kwargs):
534 Performs the query and returns a single object matching the given
537 clone = self.filter(**kwargs)
540 return clone._result_cache[0]
542 raise Task.DoesNotExist(
543 'Task matching query does not exist. '
544 'Lookup parameters were {0}'.format(kwargs))
546 'get() returned more than one Task -- it returned {0}! '
547 'Lookup parameters were {1}'.format(num, kwargs))