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'
 
  18 RECURRING = 'recurring'
 
  20 logger = logging.getLogger(__name__)
 
  23 class ReadOnlyDictView(object):
 
  25     Provides simplified read-only view upon dict object.
 
  28     def __init__(self, viewed_dict):
 
  29         self.viewed_dict = viewed_dict
 
  31     def __getitem__(self, key):
 
  32         return copy.deepcopy(self.viewed_dict.__getitem__(key))
 
  34     def __contains__(self, k):
 
  35         return self.viewed_dict.__contains__(k)
 
  38         for value in self.viewed_dict:
 
  39             yield copy.deepcopy(value)
 
  42         return len(self.viewed_dict)
 
  44     def __unicode__(self):
 
  45         return six.u('ReadOnlyDictView: {0}'.format(repr(self.viewed_dict)))
 
  47     __repr__ = __unicode__
 
  49     def get(self, key, default=None):
 
  50         return copy.deepcopy(self.viewed_dict.get(key, default))
 
  53         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
 
  56         return [copy.deepcopy(v) for v in self.viewed_dict.values()]
 
  59 class TaskResource(SerializingObject):
 
  62     def _load_data(self, data):
 
  63         self._data = dict((key, self._deserialize(key, value))
 
  64                           for key, value in data.items())
 
  65         # We need to use a copy for original data, so that changes
 
  67         self._original_data = copy.deepcopy(self._data)
 
  69     def _update_data(self, data, update_original=False, remove_missing=False):
 
  71         Low level update of the internal _data dict. Data which are coming as
 
  72         updates should already be serialized. If update_original is True, the
 
  73         original_data dict is updated as well.
 
  75         self._data.update(dict((key, self._deserialize(key, value))
 
  76                                for key, value in data.items()))
 
  78         # In certain situations, we want to treat missing keys as removals
 
  80             for key in set(self._data.keys()) - set(data.keys()):
 
  81                 self._data[key] = None
 
  84             self._original_data = copy.deepcopy(self._data)
 
  86     def __getitem__(self, key):
 
  87         # This is a workaround to make TaskResource non-iterable
 
  88         # over simple index-based iteration
 
  95         if key not in self._data:
 
  96             self._data[key] = self._deserialize(key, None)
 
  98         return self._data.get(key)
 
 100     def __setitem__(self, key, value):
 
 101         if key in self.read_only_fields:
 
 102             raise RuntimeError('Field \'%s\' is read-only' % key)
 
 104         # Normalize the user input before saving it
 
 105         value = self._normalize(key, value)
 
 106         self._data[key] = value
 
 109         s = six.text_type(self.__unicode__())
 
 111             s = s.encode('utf-8')
 
 117     def export_data(self):
 
 119         Exports current data contained in the Task as JSON
 
 122         # We need to remove spaces for TW-1504, use custom separators
 
 123         data_tuples = ((key, self._serialize(key, value))
 
 124                        for key, value in six.iteritems(self._data))
 
 126         # Empty string denotes empty serialized value, we do not want
 
 127         # to pass that to TaskWarrior.
 
 128         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
 
 129         data = dict(data_tuples)
 
 130         return json.dumps(data, separators=(',', ':'))
 
 133     def _modified_fields(self):
 
 134         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
 
 135         for key in writable_fields:
 
 136             new_value = self._data.get(key)
 
 137             old_value = self._original_data.get(key)
 
 139             # Make sure not to mark data removal as modified field if the
 
 140             # field originally had some empty value
 
 141             if key in self._data and not new_value and not old_value:
 
 144             if new_value != old_value:
 
 149         return bool(list(self._modified_fields))
 
 152 class TaskAnnotation(TaskResource):
 
 153     read_only_fields = ['entry', 'description']
 
 155     def __init__(self, task, data=None):
 
 157         self._load_data(data or dict())
 
 158         super(TaskAnnotation, self).__init__(task.backend)
 
 161         self.task.remove_annotation(self)
 
 163     def __unicode__(self):
 
 164         return self['description']
 
 166     def __eq__(self, other):
 
 167         # consider 2 annotations equal if they belong to the same task, and
 
 168         # their data dics are the same
 
 169         return self.task == other.task and self._data == other._data
 
 171     def __ne__(self, other):
 
 172         return not self.__eq__(other)
 
 174     __repr__ = __unicode__
 
 177 class Task(TaskResource):
 
 178     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
 180     class DoesNotExist(Exception):
 
 183     class CompletedTask(Exception):
 
 185         Raised when the operation cannot be performed on the completed task.
 
 189     class DeletedTask(Exception):
 
 191         Raised when the operation cannot be performed on the deleted task.
 
 195     class ActiveTask(Exception):
 
 197         Raised when the operation cannot be performed on the active task.
 
 201     class InactiveTask(Exception):
 
 203         Raised when the operation cannot be performed on an inactive task.
 
 207     class NotSaved(Exception):
 
 209         Raised when the operation cannot be performed on the task, because
 
 210         it has not been saved to TaskWarrior yet.
 
 215     def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
 
 217         Creates a Task object, directly from the stdin, by reading one line.
 
 218         If modify=True, two lines are used, first line interpreted as the
 
 219         original state of the Task object, and second line as its new,
 
 220         modified value. This is consistent with the TaskWarrior's hook
 
 223         Object created by this method should not be saved, deleted
 
 224         or refreshed, as t could create a infinite loop. For this
 
 225         reason, TaskWarrior instance is set to None.
 
 227         Input_file argument can be used to specify the input file,
 
 228         but defaults to sys.stdin.
 
 231         # Detect the hook type if not given directly
 
 232         name = os.path.basename(sys.argv[0])
 
 233         modify = name.startswith('on-modify') if modify is None else modify
 
 235         # Create the TaskWarrior instance if none passed
 
 237             backends = importlib.import_module('tasklib.backends')
 
 238             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
 
 239             backend = backends.TaskWarrior(data_location=hook_parent_dir)
 
 241         # TaskWarrior instance is set to None
 
 244         # Load the data from the input
 
 245         task._load_data(json.loads(input_file.readline().strip()))
 
 247         # If this is a on-modify event, we are provided with additional
 
 248         # line of input, which provides updated data
 
 250             task._update_data(json.loads(input_file.readline().strip()),
 
 255     def __init__(self, backend, **kwargs):
 
 256         super(Task, self).__init__(backend)
 
 258         # Check that user is not able to set read-only value in __init__
 
 259         for key in kwargs.keys():
 
 260             if key in self.read_only_fields:
 
 261                 raise RuntimeError('Field \'%s\' is read-only' % key)
 
 263         # We serialize the data in kwargs so that users of the library
 
 264         # do not have to pass different data formats via __setitem__ and
 
 265         # __init__ methods, that would be confusing
 
 267         # Rather unfortunate syntax due to python2.6 comaptiblity
 
 268         self._data = dict((key, self._normalize(key, value))
 
 269                           for (key, value) in six.iteritems(kwargs))
 
 270         self._original_data = copy.deepcopy(self._data)
 
 272         # Provide read only access to the original data
 
 273         self.original = ReadOnlyDictView(self._original_data)
 
 275     def __unicode__(self):
 
 276         return self['description']
 
 278     def __eq__(self, other):
 
 279         if self['uuid'] and other['uuid']:
 
 280             # For saved Tasks, just define equality by equality of uuids
 
 281             return self['uuid'] == other['uuid']
 
 283             # If the tasks are not saved, compare the actual instances
 
 284             return id(self) == id(other)
 
 286     def __ne__(self, other):
 
 287         return not self.__eq__(other)
 
 291             # For saved Tasks, just define equality by equality of uuids
 
 292             return self['uuid'].__hash__()
 
 294             # If the tasks are not saved, return hash of instance id
 
 295             return id(self).__hash__()
 
 299         return self['status'] == six.text_type('completed')
 
 303         return self['status'] == six.text_type('deleted')
 
 307         return self['status'] == six.text_type('waiting')
 
 311         return self['status'] == six.text_type('pending')
 
 315         return self['status'] == six.text_type('recurring')
 
 319         return self['start'] is not None
 
 323         return self['uuid'] is not None or self['id'] is not None
 
 325     def serialize_depends(self, cur_dependencies):
 
 326         # Check that all the tasks are saved
 
 327         for task in (cur_dependencies or set()):
 
 329                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 330                                     'it can be set as dependency.' % task)
 
 332         return super(Task, self).serialize_depends(cur_dependencies)
 
 337                 "Task needs to be saved before it can be deleted")
 
 339         # Refresh the status, and raise exception if the task is deleted
 
 340         self.refresh(only_fields=['status'])
 
 343             raise Task.DeletedTask("Task was already deleted")
 
 345         self.backend.delete_task(self)
 
 347         # Refresh the status again, so that we have updated info stored
 
 348         self.refresh(only_fields=['status', 'start', 'end'])
 
 353                 "Task needs to be saved before it can be started")
 
 355         # Refresh, and raise exception if task is already completed/deleted
 
 356         self.refresh(only_fields=['status'])
 
 359             raise Task.CompletedTask("Cannot start a completed task")
 
 361             raise Task.DeletedTask("Deleted task cannot be started")
 
 363             raise Task.ActiveTask("Task is already active")
 
 365         self.backend.start_task(self)
 
 367         # Refresh the status again, so that we have updated info stored
 
 368         self.refresh(only_fields=['status', 'start'])
 
 373                 "Task needs to be saved before it can be stopped")
 
 375         # Refresh, and raise exception if task is already completed/deleted
 
 376         self.refresh(only_fields=['status'])
 
 379             raise Task.InactiveTask("Cannot stop an inactive task")
 
 381         self.backend.stop_task(self)
 
 383         # Refresh the status again, so that we have updated info stored
 
 384         self.refresh(only_fields=['status', 'start'])
 
 389                 "Task needs to be saved before it can be completed")
 
 391         # Refresh, and raise exception if task is already completed/deleted
 
 392         self.refresh(only_fields=['status'])
 
 395             raise Task.CompletedTask("Cannot complete a completed task")
 
 397             raise Task.DeletedTask("Deleted task cannot be completed")
 
 399         self.backend.complete_task(self)
 
 401         # Refresh the status again, so that we have updated info stored
 
 402         self.refresh(only_fields=['status', 'start', 'end'])
 
 405         if self.saved and not self.modified:
 
 408         # All the actual work is done by the backend
 
 409         self.backend.save_task(self)
 
 411     def add_annotation(self, annotation):
 
 413             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 415         self.backend.annotate_task(self, annotation)
 
 416         self.refresh(only_fields=['annotations'])
 
 418     def remove_annotation(self, annotation):
 
 420             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 422         if isinstance(annotation, TaskAnnotation):
 
 423             annotation = annotation['description']
 
 425         self.backend.denotate_task(self, annotation)
 
 426         self.refresh(only_fields=['annotations'])
 
 428     def refresh(self, only_fields=None, after_save=False):
 
 429         # Raise error when trying to refresh a task that has not been saved
 
 431             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 433         new_data = self.backend.refresh_task(self, after_save=after_save)
 
 437                 [(k, new_data.get(k)) for k in only_fields])
 
 438             self._update_data(to_update, update_original=True)
 
 440             self._load_data(new_data)
 
 443 class TaskQuerySet(object):
 
 445     Represents a lazy lookup for a task objects.
 
 448     def __init__(self, backend, filter_obj=None):
 
 449         self.backend = backend
 
 450         self._result_cache = None
 
 451         self.filter_obj = filter_obj or self.backend.filter_class(backend)
 
 453     def __deepcopy__(self, memo):
 
 455         Deep copy of a QuerySet doesn't populate the cache
 
 457         obj = self.__class__(backend=self.backend)
 
 458         for k, v in self.__dict__.items():
 
 459             if k in ('_iter', '_result_cache'):
 
 460                 obj.__dict__[k] = None
 
 462                 obj.__dict__[k] = copy.deepcopy(v, memo)
 
 466         data = list(self[:REPR_OUTPUT_SIZE + 1])
 
 467         if len(data) > REPR_OUTPUT_SIZE:
 
 468             data[-1] = "...(remaining elements truncated)..."
 
 472         if self._result_cache is None:
 
 473             self._result_cache = list(self)
 
 474         return len(self._result_cache)
 
 477         if self._result_cache is None:
 
 478             self._result_cache = self._execute()
 
 479         return iter(self._result_cache)
 
 481     def __getitem__(self, k):
 
 482         if self._result_cache is None:
 
 483             self._result_cache = list(self)
 
 484         return self._result_cache.__getitem__(k)
 
 487         if self._result_cache is not None:
 
 488             return bool(self._result_cache)
 
 491         except StopIteration:
 
 495     def __nonzero__(self):
 
 496         return type(self).__bool__(self)
 
 498     def _clone(self, klass=None, **kwargs):
 
 500             klass = self.__class__
 
 501         filter_obj = self.filter_obj.clone()
 
 502         c = klass(backend=self.backend, filter_obj=filter_obj)
 
 503         c.__dict__.update(kwargs)
 
 508         Fetch the tasks which match the current filters.
 
 510         return self.backend.filter_tasks(self.filter_obj)
 
 514         Returns a new TaskQuerySet that is a copy of the current one.
 
 519         return self.filter(status=PENDING)
 
 522         return self.filter(status=COMPLETED)
 
 525         return self.filter(status=DELETED)
 
 528         return self.filter(status=WAITING)
 
 531         return self.filter(status=RECURRING)
 
 533     def filter(self, *args, **kwargs):
 
 535         Returns a new TaskQuerySet with the given filters added.
 
 537         clone = self._clone()
 
 539             clone.filter_obj.add_filter(f)
 
 540         for key, value in kwargs.items():
 
 541             clone.filter_obj.add_filter_param(key, value)
 
 544     def get(self, **kwargs):
 
 546         Performs the query and returns a single object matching the given
 
 549         clone = self.filter(**kwargs)
 
 552             return clone._result_cache[0]
 
 554             raise Task.DoesNotExist(
 
 555                 'Task matching query does not exist. '
 
 556                 'Lookup parameters were {0}'.format(kwargs))
 
 558             'get() returned more than one Task -- it returned {0}! '
 
 559             'Lookup parameters were {1}'.format(num, kwargs))