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 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 __repr__ = __unicode__
171 class Task(TaskResource):
172 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
174 class DoesNotExist(Exception):
177 class CompletedTask(Exception):
179 Raised when the operation cannot be performed on the completed task.
183 class DeletedTask(Exception):
185 Raised when the operation cannot be performed on the deleted task.
189 class ActiveTask(Exception):
191 Raised when the operation cannot be performed on the active task.
195 class InactiveTask(Exception):
197 Raised when the operation cannot be performed on an inactive task.
201 class NotSaved(Exception):
203 Raised when the operation cannot be performed on the task, because
204 it has not been saved to TaskWarrior yet.
209 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
211 Creates a Task object, directly from the stdin, by reading one line.
212 If modify=True, two lines are used, first line interpreted as the
213 original state of the Task object, and second line as its new,
214 modified value. This is consistent with the TaskWarrior's hook
217 Object created by this method should not be saved, deleted
218 or refreshed, as t could create a infinite loop. For this
219 reason, TaskWarrior instance is set to None.
221 Input_file argument can be used to specify the input file,
222 but defaults to sys.stdin.
225 # Detect the hook type if not given directly
226 name = os.path.basename(sys.argv[0])
227 modify = name.startswith('on-modify') if modify is None else modify
229 # Create the TaskWarrior instance if none passed
231 backends = importlib.import_module('tasklib.backends')
232 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
233 backend = backends.TaskWarrior(data_location=hook_parent_dir)
235 # TaskWarrior instance is set to None
238 # Load the data from the input
239 task._load_data(json.loads(input_file.readline().strip()))
241 # If this is a on-modify event, we are provided with additional
242 # line of input, which provides updated data
244 task._update_data(json.loads(input_file.readline().strip()),
249 def __init__(self, backend, **kwargs):
250 super(Task, self).__init__(backend)
252 # Check that user is not able to set read-only value in __init__
253 for key in kwargs.keys():
254 if key in self.read_only_fields:
255 raise RuntimeError('Field \'%s\' is read-only' % key)
257 # We serialize the data in kwargs so that users of the library
258 # do not have to pass different data formats via __setitem__ and
259 # __init__ methods, that would be confusing
261 # Rather unfortunate syntax due to python2.6 comaptiblity
262 self._data = dict((key, self._normalize(key, value))
263 for (key, value) in six.iteritems(kwargs))
264 self._original_data = copy.deepcopy(self._data)
266 # Provide read only access to the original data
267 self.original = ReadOnlyDictView(self._original_data)
269 def __unicode__(self):
270 return self['description']
272 def __eq__(self, other):
273 if self['uuid'] and other['uuid']:
274 # For saved Tasks, just define equality by equality of uuids
275 return self['uuid'] == other['uuid']
277 # If the tasks are not saved, compare the actual instances
278 return id(self) == id(other)
282 # For saved Tasks, just define equality by equality of uuids
283 return self['uuid'].__hash__()
285 # If the tasks are not saved, return hash of instance id
286 return id(self).__hash__()
290 return self['status'] == six.text_type('completed')
294 return self['status'] == six.text_type('deleted')
298 return self['status'] == six.text_type('waiting')
302 return self['status'] == six.text_type('pending')
306 return self['start'] is not None
310 return self['uuid'] is not None or self['id'] is not None
312 def serialize_depends(self, cur_dependencies):
313 # Check that all the tasks are saved
314 for task in (cur_dependencies or set()):
316 raise Task.NotSaved('Task \'%s\' needs to be saved before '
317 'it can be set as dependency.' % task)
319 return super(Task, self).serialize_depends(cur_dependencies)
323 raise Task.NotSaved("Task needs to be saved before it can be deleted")
325 # Refresh the status, and raise exception if the task is deleted
326 self.refresh(only_fields=['status'])
329 raise Task.DeletedTask("Task was already deleted")
331 self.backend.delete_task(self)
333 # Refresh the status again, so that we have updated info stored
334 self.refresh(only_fields=['status', 'start', 'end'])
338 raise Task.NotSaved("Task needs to be saved before it can be started")
340 # Refresh, and raise exception if task is already completed/deleted
341 self.refresh(only_fields=['status'])
344 raise Task.CompletedTask("Cannot start a completed task")
346 raise Task.DeletedTask("Deleted task cannot be started")
348 raise Task.ActiveTask("Task is already active")
350 self.backend.start_task(self)
352 # Refresh the status again, so that we have updated info stored
353 self.refresh(only_fields=['status', 'start'])
357 raise Task.NotSaved("Task needs to be saved before it can be stopped")
359 # Refresh, and raise exception if task is already completed/deleted
360 self.refresh(only_fields=['status'])
363 raise Task.InactiveTask("Cannot stop an inactive task")
365 self.backend.stop_task(self)
367 # Refresh the status again, so that we have updated info stored
368 self.refresh(only_fields=['status', 'start'])
372 raise Task.NotSaved("Task needs to be saved before it can be completed")
374 # Refresh, and raise exception if task is already completed/deleted
375 self.refresh(only_fields=['status'])
378 raise Task.CompletedTask("Cannot complete a completed task")
380 raise Task.DeletedTask("Deleted task cannot be completed")
382 self.backend.complete_task(self)
384 # Refresh the status again, so that we have updated info stored
385 self.refresh(only_fields=['status', 'start', 'end'])
388 if self.saved and not self.modified:
391 # All the actual work is done by the backend
392 self.backend.save_task(self)
394 def add_annotation(self, annotation):
396 raise Task.NotSaved("Task needs to be saved to add annotation")
398 self.backend.annotate_task(self, annotation)
399 self.refresh(only_fields=['annotations'])
401 def remove_annotation(self, annotation):
403 raise Task.NotSaved("Task needs to be saved to remove annotation")
405 if isinstance(annotation, TaskAnnotation):
406 annotation = annotation['description']
408 self.backend.denotate_task(self, annotation)
409 self.refresh(only_fields=['annotations'])
411 def refresh(self, only_fields=None, after_save=False):
412 # Raise error when trying to refresh a task that has not been saved
414 raise Task.NotSaved("Task needs to be saved to be refreshed")
416 new_data = self.backend.refresh_task(self, after_save=after_save)
420 [(k, new_data.get(k)) for k in only_fields])
421 self._update_data(to_update, update_original=True)
423 self._load_data(new_data)
426 class TaskQuerySet(object):
428 Represents a lazy lookup for a task objects.
431 def __init__(self, backend, filter_obj=None):
432 self.backend = backend
433 self._result_cache = None
434 self.filter_obj = filter_obj or self.backend.filter_class(backend)
436 def __deepcopy__(self, memo):
438 Deep copy of a QuerySet doesn't populate the cache
440 obj = self.__class__(backend=self.backend)
441 for k, v in self.__dict__.items():
442 if k in ('_iter', '_result_cache'):
443 obj.__dict__[k] = None
445 obj.__dict__[k] = copy.deepcopy(v, memo)
449 data = list(self[:REPR_OUTPUT_SIZE + 1])
450 if len(data) > REPR_OUTPUT_SIZE:
451 data[-1] = "...(remaining elements truncated)..."
455 if self._result_cache is None:
456 self._result_cache = list(self)
457 return len(self._result_cache)
460 if self._result_cache is None:
461 self._result_cache = self._execute()
462 return iter(self._result_cache)
464 def __getitem__(self, k):
465 if self._result_cache is None:
466 self._result_cache = list(self)
467 return self._result_cache.__getitem__(k)
470 if self._result_cache is not None:
471 return bool(self._result_cache)
474 except StopIteration:
478 def __nonzero__(self):
479 return type(self).__bool__(self)
481 def _clone(self, klass=None, **kwargs):
483 klass = self.__class__
484 filter_obj = self.filter_obj.clone()
485 c = klass(backend=self.backend, filter_obj=filter_obj)
486 c.__dict__.update(kwargs)
491 Fetch the tasks which match the current filters.
493 return self.backend.filter_tasks(self.filter_obj)
497 Returns a new TaskQuerySet that is a copy of the current one.
502 return self.filter(status=PENDING)
505 return self.filter(status=COMPLETED)
507 def filter(self, *args, **kwargs):
509 Returns a new TaskQuerySet with the given filters added.
511 clone = self._clone()
513 clone.filter_obj.add_filter(f)
514 for key, value in kwargs.items():
515 clone.filter_obj.add_filter_param(key, value)
518 def get(self, **kwargs):
520 Performs the query and returns a single object matching the given
523 clone = self.filter(**kwargs)
526 return clone._result_cache[0]
528 raise Task.DoesNotExist(
529 'Task matching query does not exist. '
530 'Lookup parameters were {0}'.format(kwargs))
532 'get() returned more than one Task -- it returned {0}! '
533 'Lookup parameters were {1}'.format(num, kwargs))