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))