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 __repr__ = __unicode__
171 class LazyUUIDTask(object):
173 A lazy wrapper around Task object, referenced by UUID.
175 - Supports comparison with LazyUUIDTask or Task objects (equality by UUIDs)
176 - If any attribute other than 'uuid' requested, a lookup in the
177 backend will be performed and this object will be replaced by a proper
181 def __init__(self, tw, uuid):
185 def __getitem__(self, key):
186 # LazyUUIDTask does not provide anything else other than 'uuid'
193 def __getattr__(self, name):
194 # Getattr is called only if the attribute could not be found using
199 def __eq__(self, other):
201 # For saved Tasks, just define equality by equality of uuids
202 return self['uuid'] == other['uuid']
205 return self['uuid'].__hash__()
209 Performs conversion to the regular Task object, referenced by the
213 replacement = self._tw.tasks.get(uuid=self._uuid)
214 self.__class__ = replacement.__class__
215 self.__dict__ = replacement.__dict__
218 class Task(TaskResource):
219 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
221 class DoesNotExist(Exception):
224 class CompletedTask(Exception):
226 Raised when the operation cannot be performed on the completed task.
230 class DeletedTask(Exception):
232 Raised when the operation cannot be performed on the deleted task.
236 class ActiveTask(Exception):
238 Raised when the operation cannot be performed on the active task.
242 class InactiveTask(Exception):
244 Raised when the operation cannot be performed on an inactive task.
248 class NotSaved(Exception):
250 Raised when the operation cannot be performed on the task, because
251 it has not been saved to TaskWarrior yet.
256 def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
258 Creates a Task object, directly from the stdin, by reading one line.
259 If modify=True, two lines are used, first line interpreted as the
260 original state of the Task object, and second line as its new,
261 modified value. This is consistent with the TaskWarrior's hook
264 Object created by this method should not be saved, deleted
265 or refreshed, as t could create a infinite loop. For this
266 reason, TaskWarrior instance is set to None.
268 Input_file argument can be used to specify the input file,
269 but defaults to sys.stdin.
272 # Detect the hook type if not given directly
273 name = os.path.basename(sys.argv[0])
274 modify = name.startswith('on-modify') if modify is None else modify
276 # Create the TaskWarrior instance if none passed
278 backends = importlib.import_module('tasklib.backends')
279 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
280 backend = backends.TaskWarrior(data_location=hook_parent_dir)
282 # TaskWarrior instance is set to None
285 # Load the data from the input
286 task._load_data(json.loads(input_file.readline().strip()))
288 # If this is a on-modify event, we are provided with additional
289 # line of input, which provides updated data
291 task._update_data(json.loads(input_file.readline().strip()),
296 def __init__(self, backend, **kwargs):
297 super(Task, self).__init__(backend)
299 # Check that user is not able to set read-only value in __init__
300 for key in kwargs.keys():
301 if key in self.read_only_fields:
302 raise RuntimeError('Field \'%s\' is read-only' % key)
304 # We serialize the data in kwargs so that users of the library
305 # do not have to pass different data formats via __setitem__ and
306 # __init__ methods, that would be confusing
308 # Rather unfortunate syntax due to python2.6 comaptiblity
309 self._data = dict((key, self._normalize(key, value))
310 for (key, value) in six.iteritems(kwargs))
311 self._original_data = copy.deepcopy(self._data)
313 # Provide read only access to the original data
314 self.original = ReadOnlyDictView(self._original_data)
316 def __unicode__(self):
317 return self['description']
319 def __eq__(self, other):
320 if self['uuid'] and other['uuid']:
321 # For saved Tasks, just define equality by equality of uuids
322 return self['uuid'] == other['uuid']
324 # If the tasks are not saved, compare the actual instances
325 return id(self) == id(other)
329 # For saved Tasks, just define equality by equality of uuids
330 return self['uuid'].__hash__()
332 # If the tasks are not saved, return hash of instance id
333 return id(self).__hash__()
337 return self['status'] == six.text_type('completed')
341 return self['status'] == six.text_type('deleted')
345 return self['status'] == six.text_type('waiting')
349 return self['status'] == six.text_type('pending')
353 return self['start'] is not None
357 return self['uuid'] is not None or self['id'] is not None
359 def serialize_depends(self, cur_dependencies):
360 # Check that all the tasks are saved
361 for task in (cur_dependencies or set()):
363 raise Task.NotSaved('Task \'%s\' needs to be saved before '
364 'it can be set as dependency.' % task)
366 return super(Task, self).serialize_depends(cur_dependencies)
370 raise Task.NotSaved("Task needs to be saved before it can be deleted")
372 # Refresh the status, and raise exception if the task is deleted
373 self.refresh(only_fields=['status'])
376 raise Task.DeletedTask("Task was already deleted")
378 self.backend.delete_task(self)
380 # Refresh the status again, so that we have updated info stored
381 self.refresh(only_fields=['status', 'start', 'end'])
385 raise Task.NotSaved("Task needs to be saved before it can be started")
387 # Refresh, and raise exception if task is already completed/deleted
388 self.refresh(only_fields=['status'])
391 raise Task.CompletedTask("Cannot start a completed task")
393 raise Task.DeletedTask("Deleted task cannot be started")
395 raise Task.ActiveTask("Task is already active")
397 self.backend.start_task(self)
399 # Refresh the status again, so that we have updated info stored
400 self.refresh(only_fields=['status', 'start'])
404 raise Task.NotSaved("Task needs to be saved before it can be stopped")
406 # Refresh, and raise exception if task is already completed/deleted
407 self.refresh(only_fields=['status'])
410 raise Task.InactiveTask("Cannot stop an inactive task")
412 self.backend.stop_task(self)
414 # Refresh the status again, so that we have updated info stored
415 self.refresh(only_fields=['status', 'start'])
419 raise Task.NotSaved("Task needs to be saved before it can be completed")
421 # Refresh, and raise exception if task is already completed/deleted
422 self.refresh(only_fields=['status'])
425 raise Task.CompletedTask("Cannot complete a completed task")
427 raise Task.DeletedTask("Deleted task cannot be completed")
429 self.backend.complete_task(self)
431 # Refresh the status again, so that we have updated info stored
432 self.refresh(only_fields=['status', 'start', 'end'])
435 if self.saved and not self.modified:
438 # All the actual work is done by the backend
439 self.backend.save_task(self)
441 def add_annotation(self, annotation):
443 raise Task.NotSaved("Task needs to be saved to add annotation")
445 self.backend.annotate_task(self, annotation)
446 self.refresh(only_fields=['annotations'])
448 def remove_annotation(self, annotation):
450 raise Task.NotSaved("Task needs to be saved to remove annotation")
452 if isinstance(annotation, TaskAnnotation):
453 annotation = annotation['description']
455 self.backend.denotate_task(self, annotation)
456 self.refresh(only_fields=['annotations'])
458 def refresh(self, only_fields=None, after_save=False):
459 # Raise error when trying to refresh a task that has not been saved
461 raise Task.NotSaved("Task needs to be saved to be refreshed")
463 new_data = self.backend.refresh_task(self, after_save=after_save)
467 [(k, new_data.get(k)) for k in only_fields])
468 self._update_data(to_update, update_original=True)
470 self._load_data(new_data)
473 class TaskQuerySet(object):
475 Represents a lazy lookup for a task objects.
478 def __init__(self, backend, filter_obj=None):
479 self.backend = backend
480 self._result_cache = None
481 self.filter_obj = filter_obj or self.backend.filter_class(backend)
483 def __deepcopy__(self, memo):
485 Deep copy of a QuerySet doesn't populate the cache
487 obj = self.__class__(backend=self.backend)
488 for k, v in self.__dict__.items():
489 if k in ('_iter', '_result_cache'):
490 obj.__dict__[k] = None
492 obj.__dict__[k] = copy.deepcopy(v, memo)
496 data = list(self[:REPR_OUTPUT_SIZE + 1])
497 if len(data) > REPR_OUTPUT_SIZE:
498 data[-1] = "...(remaining elements truncated)..."
502 if self._result_cache is None:
503 self._result_cache = list(self)
504 return len(self._result_cache)
507 if self._result_cache is None:
508 self._result_cache = self._execute()
509 return iter(self._result_cache)
511 def __getitem__(self, k):
512 if self._result_cache is None:
513 self._result_cache = list(self)
514 return self._result_cache.__getitem__(k)
517 if self._result_cache is not None:
518 return bool(self._result_cache)
521 except StopIteration:
525 def __nonzero__(self):
526 return type(self).__bool__(self)
528 def _clone(self, klass=None, **kwargs):
530 klass = self.__class__
531 filter_obj = self.filter_obj.clone()
532 c = klass(backend=self.backend, filter_obj=filter_obj)
533 c.__dict__.update(kwargs)
538 Fetch the tasks which match the current filters.
540 return self.backend.filter_tasks(self.filter_obj)
544 Returns a new TaskQuerySet that is a copy of the current one.
549 return self.filter(status=PENDING)
552 return self.filter(status=COMPLETED)
554 def filter(self, *args, **kwargs):
556 Returns a new TaskQuerySet with the given filters added.
558 clone = self._clone()
560 clone.filter_obj.add_filter(f)
561 for key, value in kwargs.items():
562 clone.filter_obj.add_filter_param(key, value)
565 def get(self, **kwargs):
567 Performs the query and returns a single object matching the given
570 clone = self.filter(**kwargs)
573 return clone._result_cache[0]
575 raise Task.DoesNotExist(
576 'Task matching query does not exist. '
577 'Lookup parameters were {0}'.format(kwargs))
579 'get() returned more than one Task -- it returned {0}! '
580 'Lookup parameters were {1}'.format(num, kwargs))