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
11 from .serializing import SerializingObject
13 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
16 COMPLETED = 'completed'
18 logger = logging.getLogger(__name__)
21 class ReadOnlyDictView(object):
23 Provides simplified read-only view upon dict object.
26 def __init__(self, viewed_dict):
27 self.viewed_dict = viewed_dict
29 def __getitem__(self, key):
30 return copy.deepcopy(self.viewed_dict.__getitem__(key))
32 def __contains__(self, k):
33 return self.viewed_dict.__contains__(k)
36 for value in self.viewed_dict:
37 yield copy.deepcopy(value)
40 return len(self.viewed_dict)
42 def get(self, key, default=None):
43 return copy.deepcopy(self.viewed_dict.get(key, default))
46 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
49 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
52 class TaskResource(SerializingObject):
55 def _load_data(self, data):
56 self._data = dict((key, self._deserialize(key, value))
57 for key, value in data.items())
58 # We need to use a copy for original data, so that changes
60 self._original_data = copy.deepcopy(self._data)
62 def _update_data(self, data, update_original=False, remove_missing=False):
64 Low level update of the internal _data dict. Data which are coming as
65 updates should already be serialized. If update_original is True, the
66 original_data dict is updated as well.
68 self._data.update(dict((key, self._deserialize(key, value))
69 for key, value in data.items()))
71 # In certain situations, we want to treat missing keys as removals
73 for key in set(self._data.keys()) - set(data.keys()):
74 self._data[key] = None
77 self._original_data = copy.deepcopy(self._data)
80 def __getitem__(self, key):
81 # This is a workaround to make TaskResource non-iterable
82 # over simple index-based iteration
89 if key not in self._data:
90 self._data[key] = self._deserialize(key, None)
92 return self._data.get(key)
94 def __setitem__(self, key, value):
95 if key in self.read_only_fields:
96 raise RuntimeError('Field \'%s\' is read-only' % key)
98 # Normalize the user input before saving it
99 value = self._normalize(key, value)
100 self._data[key] = value
103 s = six.text_type(self.__unicode__())
105 s = s.encode('utf-8')
111 def export_data(self):
113 Exports current data contained in the Task as JSON
116 # We need to remove spaces for TW-1504, use custom separators
117 data_tuples = ((key, self._serialize(key, value))
118 for key, value in six.iteritems(self._data))
120 # Empty string denotes empty serialized value, we do not want
121 # to pass that to TaskWarrior.
122 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
123 data = dict(data_tuples)
124 return json.dumps(data, separators=(',',':'))
127 def _modified_fields(self):
128 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
129 for key in writable_fields:
130 new_value = self._data.get(key)
131 old_value = self._original_data.get(key)
133 # Make sure not to mark data removal as modified field if the
134 # field originally had some empty value
135 if key in self._data and not new_value and not old_value:
138 if new_value != old_value:
143 return bool(list(self._modified_fields))
146 class TaskAnnotation(TaskResource):
147 read_only_fields = ['entry', 'description']
149 def __init__(self, task, data=None):
151 self._load_data(data or dict())
152 super(TaskAnnotation, self).__init__(task.backend)
155 self.task.remove_annotation(self)
157 def __unicode__(self):
158 return self['description']
160 def __eq__(self, other):
161 # consider 2 annotations equal if they belong to the same task, and
162 # their data dics are the same
163 return self.task == other.task and self._data == other._data
165 __repr__ = __unicode__
168 class Task(TaskResource):
169 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
171 class DoesNotExist(Exception):
174 class CompletedTask(Exception):
176 Raised when the operation cannot be performed on the completed task.
180 class DeletedTask(Exception):
182 Raised when the operation cannot be performed on the deleted task.
186 class ActiveTask(Exception):
188 Raised when the operation cannot be performed on the active task.
192 class InactiveTask(Exception):
194 Raised when the operation cannot be performed on an inactive task.
198 class NotSaved(Exception):
200 Raised when the operation cannot be performed on the task, because
201 it has not been saved to TaskWarrior yet.
206 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
208 Creates a Task object, directly from the stdin, by reading one line.
209 If modify=True, two lines are used, first line interpreted as the
210 original state of the Task object, and second line as its new,
211 modified value. This is consistent with the TaskWarrior's hook
214 Object created by this method should not be saved, deleted
215 or refreshed, as t could create a infinite loop. For this
216 reason, TaskWarrior instance is set to None.
218 Input_file argument can be used to specify the input file,
219 but defaults to sys.stdin.
222 # Detect the hook type if not given directly
223 name = os.path.basename(sys.argv[0])
224 modify = name.startswith('on-modify') if modify is None else modify
226 # Create the TaskWarrior instance if none passed
228 backends = importlib.import_module('.backends')
229 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
230 backend = backends.TaskWarrior(data_location=hook_parent_dir)
232 # TaskWarrior instance is set to None
235 # Load the data from the input
236 task._load_data(json.loads(input_file.readline().strip()))
238 # If this is a on-modify event, we are provided with additional
239 # line of input, which provides updated data
241 task._update_data(json.loads(input_file.readline().strip()),
246 def __init__(self, backend, **kwargs):
247 super(Task, self).__init__(backend)
249 # Check that user is not able to set read-only value in __init__
250 for key in kwargs.keys():
251 if key in self.read_only_fields:
252 raise RuntimeError('Field \'%s\' is read-only' % key)
254 # We serialize the data in kwargs so that users of the library
255 # do not have to pass different data formats via __setitem__ and
256 # __init__ methods, that would be confusing
258 # Rather unfortunate syntax due to python2.6 comaptiblity
259 self._data = dict((key, self._normalize(key, value))
260 for (key, value) in six.iteritems(kwargs))
261 self._original_data = copy.deepcopy(self._data)
263 # Provide read only access to the original data
264 self.original = ReadOnlyDictView(self._original_data)
266 def __unicode__(self):
267 return self['description']
269 def __eq__(self, other):
270 if self['uuid'] and other['uuid']:
271 # For saved Tasks, just define equality by equality of uuids
272 return self['uuid'] == other['uuid']
274 # If the tasks are not saved, compare the actual instances
275 return id(self) == id(other)
280 # For saved Tasks, just define equality by equality of uuids
281 return self['uuid'].__hash__()
283 # If the tasks are not saved, return hash of instance id
284 return id(self).__hash__()
288 return self['status'] == six.text_type('completed')
292 return self['status'] == six.text_type('deleted')
296 return self['status'] == six.text_type('waiting')
300 return self['status'] == six.text_type('pending')
304 return self['start'] is not None
308 return self['uuid'] is not None or self['id'] is not None
310 def serialize_depends(self, cur_dependencies):
311 # Check that all the tasks are saved
312 for task in (cur_dependencies or set()):
314 raise Task.NotSaved('Task \'%s\' needs to be saved before '
315 'it can be set as dependency.' % task)
317 return super(Task, self).serialize_depends(cur_dependencies)
321 raise Task.NotSaved("Task needs to be saved before it can be deleted")
323 # Refresh the status, and raise exception if the task is deleted
324 self.refresh(only_fields=['status'])
327 raise Task.DeletedTask("Task was already deleted")
329 self.backend.delete_task(self)
331 # Refresh the status again, so that we have updated info stored
332 self.refresh(only_fields=['status', 'start', 'end'])
336 raise Task.NotSaved("Task needs to be saved before it can be started")
338 # Refresh, and raise exception if task is already completed/deleted
339 self.refresh(only_fields=['status'])
342 raise Task.CompletedTask("Cannot start a completed task")
344 raise Task.DeletedTask("Deleted task cannot be started")
346 raise Task.ActiveTask("Task is already active")
348 self.backend.start_task(self)
350 # Refresh the status again, so that we have updated info stored
351 self.refresh(only_fields=['status', 'start'])
355 raise Task.NotSaved("Task needs to be saved before it can be stopped")
357 # Refresh, and raise exception if task is already completed/deleted
358 self.refresh(only_fields=['status'])
361 raise Task.InactiveTask("Cannot stop an inactive task")
363 self.backend.stop_task(self)
365 # Refresh the status again, so that we have updated info stored
366 self.refresh(only_fields=['status', 'start'])
370 raise Task.NotSaved("Task needs to be saved before it can be completed")
372 # Refresh, and raise exception if task is already completed/deleted
373 self.refresh(only_fields=['status'])
376 raise Task.CompletedTask("Cannot complete a completed task")
378 raise Task.DeletedTask("Deleted task cannot be completed")
380 self.backend.complete_task(self)
382 # Refresh the status again, so that we have updated info stored
383 self.refresh(only_fields=['status', 'start', 'end'])
386 if self.saved and not self.modified:
389 # All the actual work is done by the backend
390 self.backend.save_task(self)
392 def add_annotation(self, annotation):
394 raise Task.NotSaved("Task needs to be saved to add annotation")
396 self.backend.annotate_task(self, annotation)
397 self.refresh(only_fields=['annotations'])
399 def remove_annotation(self, annotation):
401 raise Task.NotSaved("Task needs to be saved to remove annotation")
403 if isinstance(annotation, TaskAnnotation):
404 annotation = annotation['description']
406 self.backend.denotate_task(self, annotation)
407 self.refresh(only_fields=['annotations'])
409 def refresh(self, only_fields=None, after_save=False):
410 # Raise error when trying to refresh a task that has not been saved
412 raise Task.NotSaved("Task needs to be saved to be refreshed")
414 new_data = self.backend.refresh_task(self, after_save=after_save)
418 [(k, new_data.get(k)) for k in only_fields])
419 self._update_data(to_update, update_original=True)
421 self._load_data(new_data)
423 class TaskQuerySet(object):
425 Represents a lazy lookup for a task objects.
428 def __init__(self, backend, filter_obj=None):
429 self.backend = backend
430 self._result_cache = None
431 self.filter_obj = filter_obj or self.backend.filter_class(backend)
433 def __deepcopy__(self, memo):
435 Deep copy of a QuerySet doesn't populate the cache
437 obj = self.__class__(backend=self.backend)
438 for k, v in self.__dict__.items():
439 if k in ('_iter', '_result_cache'):
440 obj.__dict__[k] = None
442 obj.__dict__[k] = copy.deepcopy(v, memo)
446 data = list(self[:REPR_OUTPUT_SIZE + 1])
447 if len(data) > REPR_OUTPUT_SIZE:
448 data[-1] = "...(remaining elements truncated)..."
452 if self._result_cache is None:
453 self._result_cache = list(self)
454 return len(self._result_cache)
457 if self._result_cache is None:
458 self._result_cache = self._execute()
459 return iter(self._result_cache)
461 def __getitem__(self, k):
462 if self._result_cache is None:
463 self._result_cache = list(self)
464 return self._result_cache.__getitem__(k)
467 if self._result_cache is not None:
468 return bool(self._result_cache)
471 except StopIteration:
475 def __nonzero__(self):
476 return type(self).__bool__(self)
478 def _clone(self, klass=None, **kwargs):
480 klass = self.__class__
481 filter_obj = self.filter_obj.clone()
482 c = klass(backend=self.backend, filter_obj=filter_obj)
483 c.__dict__.update(kwargs)
488 Fetch the tasks which match the current filters.
490 return self.backend.filter_tasks(self.filter_obj)
494 Returns a new TaskQuerySet that is a copy of the current one.
499 return self.filter(status=PENDING)
502 return self.filter(status=COMPLETED)
504 def filter(self, *args, **kwargs):
506 Returns a new TaskQuerySet with the given filters added.
508 clone = self._clone()
510 clone.filter_obj.add_filter(f)
511 for key, value in kwargs.items():
512 clone.filter_obj.add_filter_param(key, value)
515 def get(self, **kwargs):
517 Performs the query and returns a single object matching the given
520 clone = self.filter(**kwargs)
523 return clone._result_cache[0]
525 raise Task.DoesNotExist(
526 'Task matching query does not exist. '
527 'Lookup parameters were {0}'.format(kwargs))
529 'get() returned more than one Task -- it returned {0}! '
530 'Lookup parameters were {1}'.format(num, kwargs))