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['status'] == six.text_type('recurring')
 
 318         return self['start'] is not None
 
 322         return self['uuid'] is not None or self['id'] is not None
 
 324     def serialize_depends(self, cur_dependencies):
 
 325         # Check that all the tasks are saved
 
 326         for task in (cur_dependencies or set()):
 
 328                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 329                                     'it can be set as dependency.' % task)
 
 331         return super(Task, self).serialize_depends(cur_dependencies)
 
 335             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
 337         # Refresh the status, and raise exception if the task is deleted
 
 338         self.refresh(only_fields=['status'])
 
 341             raise Task.DeletedTask("Task was already deleted")
 
 343         self.backend.delete_task(self)
 
 345         # Refresh the status again, so that we have updated info stored
 
 346         self.refresh(only_fields=['status', 'start', 'end'])
 
 350             raise Task.NotSaved("Task needs to be saved before it can be started")
 
 352         # Refresh, and raise exception if task is already completed/deleted
 
 353         self.refresh(only_fields=['status'])
 
 356             raise Task.CompletedTask("Cannot start a completed task")
 
 358             raise Task.DeletedTask("Deleted task cannot be started")
 
 360             raise Task.ActiveTask("Task is already active")
 
 362         self.backend.start_task(self)
 
 364         # Refresh the status again, so that we have updated info stored
 
 365         self.refresh(only_fields=['status', 'start'])
 
 369             raise Task.NotSaved("Task needs to be saved before it can be stopped")
 
 371         # Refresh, and raise exception if task is already completed/deleted
 
 372         self.refresh(only_fields=['status'])
 
 375             raise Task.InactiveTask("Cannot stop an inactive task")
 
 377         self.backend.stop_task(self)
 
 379         # Refresh the status again, so that we have updated info stored
 
 380         self.refresh(only_fields=['status', 'start'])
 
 384             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
 386         # Refresh, and raise exception if task is already completed/deleted
 
 387         self.refresh(only_fields=['status'])
 
 390             raise Task.CompletedTask("Cannot complete a completed task")
 
 392             raise Task.DeletedTask("Deleted task cannot be completed")
 
 394         self.backend.complete_task(self)
 
 396         # Refresh the status again, so that we have updated info stored
 
 397         self.refresh(only_fields=['status', 'start', 'end'])
 
 400         if self.saved and not self.modified:
 
 403         # All the actual work is done by the backend
 
 404         self.backend.save_task(self)
 
 406     def add_annotation(self, annotation):
 
 408             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 410         self.backend.annotate_task(self, annotation)
 
 411         self.refresh(only_fields=['annotations'])
 
 413     def remove_annotation(self, annotation):
 
 415             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 417         if isinstance(annotation, TaskAnnotation):
 
 418             annotation = annotation['description']
 
 420         self.backend.denotate_task(self, annotation)
 
 421         self.refresh(only_fields=['annotations'])
 
 423     def refresh(self, only_fields=None, after_save=False):
 
 424         # Raise error when trying to refresh a task that has not been saved
 
 426             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 428         new_data = self.backend.refresh_task(self, after_save=after_save)
 
 432                 [(k, new_data.get(k)) for k in only_fields])
 
 433             self._update_data(to_update, update_original=True)
 
 435             self._load_data(new_data)
 
 438 class TaskQuerySet(object):
 
 440     Represents a lazy lookup for a task objects.
 
 443     def __init__(self, backend, filter_obj=None):
 
 444         self.backend = backend
 
 445         self._result_cache = None
 
 446         self.filter_obj = filter_obj or self.backend.filter_class(backend)
 
 448     def __deepcopy__(self, memo):
 
 450         Deep copy of a QuerySet doesn't populate the cache
 
 452         obj = self.__class__(backend=self.backend)
 
 453         for k, v in self.__dict__.items():
 
 454             if k in ('_iter', '_result_cache'):
 
 455                 obj.__dict__[k] = None
 
 457                 obj.__dict__[k] = copy.deepcopy(v, memo)
 
 461         data = list(self[:REPR_OUTPUT_SIZE + 1])
 
 462         if len(data) > REPR_OUTPUT_SIZE:
 
 463             data[-1] = "...(remaining elements truncated)..."
 
 467         if self._result_cache is None:
 
 468             self._result_cache = list(self)
 
 469         return len(self._result_cache)
 
 472         if self._result_cache is None:
 
 473             self._result_cache = self._execute()
 
 474         return iter(self._result_cache)
 
 476     def __getitem__(self, k):
 
 477         if self._result_cache is None:
 
 478             self._result_cache = list(self)
 
 479         return self._result_cache.__getitem__(k)
 
 482         if self._result_cache is not None:
 
 483             return bool(self._result_cache)
 
 486         except StopIteration:
 
 490     def __nonzero__(self):
 
 491         return type(self).__bool__(self)
 
 493     def _clone(self, klass=None, **kwargs):
 
 495             klass = self.__class__
 
 496         filter_obj = self.filter_obj.clone()
 
 497         c = klass(backend=self.backend, filter_obj=filter_obj)
 
 498         c.__dict__.update(kwargs)
 
 503         Fetch the tasks which match the current filters.
 
 505         return self.backend.filter_tasks(self.filter_obj)
 
 509         Returns a new TaskQuerySet that is a copy of the current one.
 
 514         return self.filter(status=PENDING)
 
 517         return self.filter(status=COMPLETED)
 
 520         return self.filter(status=DELETED)
 
 523         return self.filter(status=WAITING)
 
 525     def filter(self, *args, **kwargs):
 
 527         Returns a new TaskQuerySet with the given filters added.
 
 529         clone = self._clone()
 
 531             clone.filter_obj.add_filter(f)
 
 532         for key, value in kwargs.items():
 
 533             clone.filter_obj.add_filter_param(key, value)
 
 536     def get(self, **kwargs):
 
 538         Performs the query and returns a single object matching the given
 
 541         clone = self.filter(**kwargs)
 
 544             return clone._result_cache[0]
 
 546             raise Task.DoesNotExist(
 
 547                 'Task matching query does not exist. '
 
 548                 'Lookup parameters were {0}'.format(kwargs))
 
 550             'get() returned more than one Task -- it returned {0}! '
 
 551             'Lookup parameters were {1}'.format(num, kwargs))