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
 
  12 from backends import TaskWarrior, TaskWarriorException
 
  13 from serializing import SerializingObject
 
  15 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
 
  16 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
 
  19 COMPLETED = 'completed'
 
  21 logger = logging.getLogger(__name__)
 
  22 local_zone = tzlocal.get_localzone()
 
  25 class ReadOnlyDictView(object):
 
  27     Provides simplified read-only view upon dict object.
 
  30     def __init__(self, viewed_dict):
 
  31         self.viewed_dict = viewed_dict
 
  33     def __getitem__(self, key):
 
  34         return copy.deepcopy(self.viewed_dict.__getitem__(key))
 
  36     def __contains__(self, k):
 
  37         return self.viewed_dict.__contains__(k)
 
  40         for value in self.viewed_dict:
 
  41             yield copy.deepcopy(value)
 
  44         return len(self.viewed_dict)
 
  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)
 
  84     def __getitem__(self, key):
 
  85         # This is a workaround to make TaskResource non-iterable
 
  86         # over simple index-based iteration
 
  93         if key not in self._data:
 
  94             self._data[key] = self._deserialize(key, None)
 
  96         return self._data.get(key)
 
  98     def __setitem__(self, key, value):
 
  99         if key in self.read_only_fields:
 
 100             raise RuntimeError('Field \'%s\' is read-only' % key)
 
 102         # Normalize the user input before saving it
 
 103         value = self._normalize(key, value)
 
 104         self._data[key] = value
 
 107         s = six.text_type(self.__unicode__())
 
 109             s = s.encode('utf-8')
 
 115     def export_data(self):
 
 117         Exports current data contained in the Task as JSON
 
 120         # We need to remove spaces for TW-1504, use custom separators
 
 121         data_tuples = ((key, self._serialize(key, value))
 
 122                        for key, value in six.iteritems(self._data))
 
 124         # Empty string denotes empty serialized value, we do not want
 
 125         # to pass that to TaskWarrior.
 
 126         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
 
 127         data = dict(data_tuples)
 
 128         return json.dumps(data, separators=(',',':'))
 
 131     def _modified_fields(self):
 
 132         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
 
 133         for key in writable_fields:
 
 134             new_value = self._data.get(key)
 
 135             old_value = self._original_data.get(key)
 
 137             # Make sure not to mark data removal as modified field if the
 
 138             # field originally had some empty value
 
 139             if key in self._data and not new_value and not old_value:
 
 142             if new_value != old_value:
 
 147         return bool(list(self._modified_fields))
 
 150 class TaskAnnotation(TaskResource):
 
 151     read_only_fields = ['entry', 'description']
 
 153     def __init__(self, task, data=None):
 
 155         self._load_data(data or dict())
 
 156         super(TaskAnnotation, self).__init__(task.warrior)
 
 159         self.task.remove_annotation(self)
 
 161     def __unicode__(self):
 
 162         return self['description']
 
 164     def __eq__(self, other):
 
 165         # consider 2 annotations equal if they belong to the same task, and
 
 166         # their data dics are the same
 
 167         return self.task == other.task and self._data == other._data
 
 169     __repr__ = __unicode__
 
 172 class Task(TaskResource):
 
 173     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
 175     class DoesNotExist(Exception):
 
 178     class CompletedTask(Exception):
 
 180         Raised when the operation cannot be performed on the completed task.
 
 184     class DeletedTask(Exception):
 
 186         Raised when the operation cannot be performed on the deleted task.
 
 190     class ActiveTask(Exception):
 
 192         Raised when the operation cannot be performed on the active task.
 
 196     class InactiveTask(Exception):
 
 198         Raised when the operation cannot be performed on an inactive task.
 
 202     class NotSaved(Exception):
 
 204         Raised when the operation cannot be performed on the task, because
 
 205         it has not been saved to TaskWarrior yet.
 
 210     def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
 
 212         Creates a Task object, directly from the stdin, by reading one line.
 
 213         If modify=True, two lines are used, first line interpreted as the
 
 214         original state of the Task object, and second line as its new,
 
 215         modified value. This is consistent with the TaskWarrior's hook
 
 218         Object created by this method should not be saved, deleted
 
 219         or refreshed, as t could create a infinite loop. For this
 
 220         reason, TaskWarrior instance is set to None.
 
 222         Input_file argument can be used to specify the input file,
 
 223         but defaults to sys.stdin.
 
 226         # Detect the hook type if not given directly
 
 227         name = os.path.basename(sys.argv[0])
 
 228         modify = name.startswith('on-modify') if modify is None else modify
 
 230         # Create the TaskWarrior instance if none passed
 
 232             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
 
 233             warrior = 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, warrior, **kwargs):
 
 250         super(Task, self).__init__(warrior)
 
 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)
 
 283             # For saved Tasks, just define equality by equality of uuids
 
 284             return self['uuid'].__hash__()
 
 286             # If the tasks are not saved, return hash of instance id
 
 287             return id(self).__hash__()
 
 291         return self['status'] == six.text_type('completed')
 
 295         return self['status'] == six.text_type('deleted')
 
 299         return self['status'] == six.text_type('waiting')
 
 303         return self['status'] == six.text_type('pending')
 
 307         return self['start'] is not None
 
 311         return self['uuid'] is not None or self['id'] is not None
 
 313     def serialize_depends(self, cur_dependencies):
 
 314         # Check that all the tasks are saved
 
 315         for task in (cur_dependencies or set()):
 
 317                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 318                                     'it can be set as dependency.' % task)
 
 320         return super(Task, self).serialize_depends(cur_dependencies)
 
 324             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
 326         # Refresh the status, and raise exception if the task is deleted
 
 327         self.refresh(only_fields=['status'])
 
 330             raise Task.DeletedTask("Task was already deleted")
 
 332         self.backend.delete_task(self)
 
 334         # Refresh the status again, so that we have updated info stored
 
 335         self.refresh(only_fields=['status', 'start', 'end'])
 
 339             raise Task.NotSaved("Task needs to be saved before it can be started")
 
 341         # Refresh, and raise exception if task is already completed/deleted
 
 342         self.refresh(only_fields=['status'])
 
 345             raise Task.CompletedTask("Cannot start a completed task")
 
 347             raise Task.DeletedTask("Deleted task cannot be started")
 
 349             raise Task.ActiveTask("Task is already active")
 
 351         self.backend.start_task(self)
 
 353         # Refresh the status again, so that we have updated info stored
 
 354         self.refresh(only_fields=['status', 'start'])
 
 358             raise Task.NotSaved("Task needs to be saved before it can be stopped")
 
 360         # Refresh, and raise exception if task is already completed/deleted
 
 361         self.refresh(only_fields=['status'])
 
 364             raise Task.InactiveTask("Cannot stop an inactive task")
 
 366         self.backend.stop_task(self)
 
 368         # Refresh the status again, so that we have updated info stored
 
 369         self.refresh(only_fields=['status', 'start'])
 
 373             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
 375         # Refresh, and raise exception if task is already completed/deleted
 
 376         self.refresh(only_fields=['status'])
 
 379             raise Task.CompletedTask("Cannot complete a completed task")
 
 381             raise Task.DeletedTask("Deleted task cannot be completed")
 
 383         self.backend.complete_task(self)
 
 385         # Refresh the status again, so that we have updated info stored
 
 386         self.refresh(only_fields=['status', 'start', 'end'])
 
 389         if self.saved and not self.modified:
 
 392         # All the actual work is done by the backend
 
 393         self.backend.save_task(self)
 
 395     def add_annotation(self, annotation):
 
 397             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 399         self.backend.annotate_task(self, annotation)
 
 400         self.refresh(only_fields=['annotations'])
 
 402     def remove_annotation(self, annotation):
 
 404             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 406         if isinstance(annotation, TaskAnnotation):
 
 407             annotation = annotation['description']
 
 409         self.backend.denotate_task(self, annotation)
 
 410         self.refresh(only_fields=['annotations'])
 
 412     def refresh(self, only_fields=None, after_save=False):
 
 413         # Raise error when trying to refresh a task that has not been saved
 
 415             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 417         new_data = self.backend.refresh_task(self, after_save=after_save)
 
 421                 [(k, new_data.get(k)) for k in only_fields])
 
 422             self._update_data(to_update, update_original=True)
 
 424             self._load_data(new_data)
 
 426 class TaskQuerySet(object):
 
 428     Represents a lazy lookup for a task objects.
 
 431     def __init__(self, warrior=None, filter_obj=None):
 
 432         self.warrior = warrior
 
 433         self._result_cache = None
 
 434         self.filter_obj = filter_obj or TaskWarriorFilter(warrior)
 
 436     def __deepcopy__(self, memo):
 
 438         Deep copy of a QuerySet doesn't populate the cache
 
 440         obj = self.__class__()
 
 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(warrior=self.warrior, filter_obj=filter_obj)
 
 486         c.__dict__.update(kwargs)
 
 491         Fetch the tasks which match the current filters.
 
 493         return self.warrior.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))