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 get(self, key, default=None):
 
  42         return copy.deepcopy(self.viewed_dict.get(key, default))
 
  45         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
 
  48         return [copy.deepcopy(v) for v in self.viewed_dict.values()]
 
  51 class TaskResource(SerializingObject):
 
  54     def _load_data(self, data):
 
  55         self._data = dict((key, self._deserialize(key, value))
 
  56                           for key, value in data.items())
 
  57         # We need to use a copy for original data, so that changes
 
  59         self._original_data = copy.deepcopy(self._data)
 
  61     def _update_data(self, data, update_original=False, remove_missing=False):
 
  63         Low level update of the internal _data dict. Data which are coming as
 
  64         updates should already be serialized. If update_original is True, the
 
  65         original_data dict is updated as well.
 
  67         self._data.update(dict((key, self._deserialize(key, value))
 
  68                                for key, value in data.items()))
 
  70         # In certain situations, we want to treat missing keys as removals
 
  72             for key in set(self._data.keys()) - set(data.keys()):
 
  73                 self._data[key] = None
 
  76             self._original_data = copy.deepcopy(self._data)
 
  78     def __getitem__(self, key):
 
  79         # This is a workaround to make TaskResource non-iterable
 
  80         # over simple index-based iteration
 
  87         if key not in self._data:
 
  88             self._data[key] = self._deserialize(key, None)
 
  90         return self._data.get(key)
 
  92     def __setitem__(self, key, value):
 
  93         if key in self.read_only_fields:
 
  94             raise RuntimeError('Field \'%s\' is read-only' % key)
 
  96         # Normalize the user input before saving it
 
  97         value = self._normalize(key, value)
 
  98         self._data[key] = value
 
 101         s = six.text_type(self.__unicode__())
 
 103             s = s.encode('utf-8')
 
 109     def export_data(self):
 
 111         Exports current data contained in the Task as JSON
 
 114         # We need to remove spaces for TW-1504, use custom separators
 
 115         data_tuples = ((key, self._serialize(key, value))
 
 116                        for key, value in six.iteritems(self._data))
 
 118         # Empty string denotes empty serialized value, we do not want
 
 119         # to pass that to TaskWarrior.
 
 120         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
 
 121         data = dict(data_tuples)
 
 122         return json.dumps(data, separators=(',', ':'))
 
 125     def _modified_fields(self):
 
 126         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
 
 127         for key in writable_fields:
 
 128             new_value = self._data.get(key)
 
 129             old_value = self._original_data.get(key)
 
 131             # Make sure not to mark data removal as modified field if the
 
 132             # field originally had some empty value
 
 133             if key in self._data and not new_value and not old_value:
 
 136             if new_value != old_value:
 
 141         return bool(list(self._modified_fields))
 
 144 class TaskAnnotation(TaskResource):
 
 145     read_only_fields = ['entry', 'description']
 
 147     def __init__(self, task, data=None):
 
 149         self._load_data(data or dict())
 
 150         super(TaskAnnotation, self).__init__(task.backend)
 
 153         self.task.remove_annotation(self)
 
 155     def __unicode__(self):
 
 156         return self['description']
 
 158     def __eq__(self, other):
 
 159         # consider 2 annotations equal if they belong to the same task, and
 
 160         # their data dics are the same
 
 161         return self.task == other.task and self._data == other._data
 
 163     __repr__ = __unicode__
 
 166 class Task(TaskResource):
 
 167     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
 169     class DoesNotExist(Exception):
 
 172     class CompletedTask(Exception):
 
 174         Raised when the operation cannot be performed on the completed task.
 
 178     class DeletedTask(Exception):
 
 180         Raised when the operation cannot be performed on the deleted task.
 
 184     class ActiveTask(Exception):
 
 186         Raised when the operation cannot be performed on the active task.
 
 190     class InactiveTask(Exception):
 
 192         Raised when the operation cannot be performed on an inactive task.
 
 196     class NotSaved(Exception):
 
 198         Raised when the operation cannot be performed on the task, because
 
 199         it has not been saved to TaskWarrior yet.
 
 204     def from_input(cls, input_file=sys.stdin, modify=None, backend=None):
 
 206         Creates a Task object, directly from the stdin, by reading one line.
 
 207         If modify=True, two lines are used, first line interpreted as the
 
 208         original state of the Task object, and second line as its new,
 
 209         modified value. This is consistent with the TaskWarrior's hook
 
 212         Object created by this method should not be saved, deleted
 
 213         or refreshed, as t could create a infinite loop. For this
 
 214         reason, TaskWarrior instance is set to None.
 
 216         Input_file argument can be used to specify the input file,
 
 217         but defaults to sys.stdin.
 
 220         # Detect the hook type if not given directly
 
 221         name = os.path.basename(sys.argv[0])
 
 222         modify = name.startswith('on-modify') if modify is None else modify
 
 224         # Create the TaskWarrior instance if none passed
 
 226             backends = importlib.import_module('tasklib.backends')
 
 227             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
 
 228             backend = backends.TaskWarrior(data_location=hook_parent_dir)
 
 230         # TaskWarrior instance is set to None
 
 233         # Load the data from the input
 
 234         task._load_data(json.loads(input_file.readline().strip()))
 
 236         # If this is a on-modify event, we are provided with additional
 
 237         # line of input, which provides updated data
 
 239             task._update_data(json.loads(input_file.readline().strip()),
 
 244     def __init__(self, backend, **kwargs):
 
 245         super(Task, self).__init__(backend)
 
 247         # Check that user is not able to set read-only value in __init__
 
 248         for key in kwargs.keys():
 
 249             if key in self.read_only_fields:
 
 250                 raise RuntimeError('Field \'%s\' is read-only' % key)
 
 252         # We serialize the data in kwargs so that users of the library
 
 253         # do not have to pass different data formats via __setitem__ and
 
 254         # __init__ methods, that would be confusing
 
 256         # Rather unfortunate syntax due to python2.6 comaptiblity
 
 257         self._data = dict((key, self._normalize(key, value))
 
 258                           for (key, value) in six.iteritems(kwargs))
 
 259         self._original_data = copy.deepcopy(self._data)
 
 261         # Provide read only access to the original data
 
 262         self.original = ReadOnlyDictView(self._original_data)
 
 264     def __unicode__(self):
 
 265         return self['description']
 
 267     def __eq__(self, other):
 
 268         if self['uuid'] and other['uuid']:
 
 269             # For saved Tasks, just define equality by equality of uuids
 
 270             return self['uuid'] == other['uuid']
 
 272             # If the tasks are not saved, compare the actual instances
 
 273             return id(self) == id(other)
 
 277             # For saved Tasks, just define equality by equality of uuids
 
 278             return self['uuid'].__hash__()
 
 280             # If the tasks are not saved, return hash of instance id
 
 281             return id(self).__hash__()
 
 285         return self['status'] == six.text_type('completed')
 
 289         return self['status'] == six.text_type('deleted')
 
 293         return self['status'] == six.text_type('waiting')
 
 297         return self['status'] == six.text_type('pending')
 
 301         return self['start'] is not None
 
 305         return self['uuid'] is not None or self['id'] is not None
 
 307     def serialize_depends(self, cur_dependencies):
 
 308         # Check that all the tasks are saved
 
 309         for task in (cur_dependencies or set()):
 
 311                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 312                                     'it can be set as dependency.' % task)
 
 314         return super(Task, self).serialize_depends(cur_dependencies)
 
 318             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
 320         # Refresh the status, and raise exception if the task is deleted
 
 321         self.refresh(only_fields=['status'])
 
 324             raise Task.DeletedTask("Task was already deleted")
 
 326         self.backend.delete_task(self)
 
 328         # Refresh the status again, so that we have updated info stored
 
 329         self.refresh(only_fields=['status', 'start', 'end'])
 
 333             raise Task.NotSaved("Task needs to be saved before it can be started")
 
 335         # Refresh, and raise exception if task is already completed/deleted
 
 336         self.refresh(only_fields=['status'])
 
 339             raise Task.CompletedTask("Cannot start a completed task")
 
 341             raise Task.DeletedTask("Deleted task cannot be started")
 
 343             raise Task.ActiveTask("Task is already active")
 
 345         self.backend.start_task(self)
 
 347         # Refresh the status again, so that we have updated info stored
 
 348         self.refresh(only_fields=['status', 'start'])
 
 352             raise Task.NotSaved("Task needs to be saved before it can be stopped")
 
 354         # Refresh, and raise exception if task is already completed/deleted
 
 355         self.refresh(only_fields=['status'])
 
 358             raise Task.InactiveTask("Cannot stop an inactive task")
 
 360         self.backend.stop_task(self)
 
 362         # Refresh the status again, so that we have updated info stored
 
 363         self.refresh(only_fields=['status', 'start'])
 
 367             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
 369         # Refresh, and raise exception if task is already completed/deleted
 
 370         self.refresh(only_fields=['status'])
 
 373             raise Task.CompletedTask("Cannot complete a completed task")
 
 375             raise Task.DeletedTask("Deleted task cannot be completed")
 
 377         self.backend.complete_task(self)
 
 379         # Refresh the status again, so that we have updated info stored
 
 380         self.refresh(only_fields=['status', 'start', 'end'])
 
 383         if self.saved and not self.modified:
 
 386         # All the actual work is done by the backend
 
 387         self.backend.save_task(self)
 
 389     def add_annotation(self, annotation):
 
 391             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 393         self.backend.annotate_task(self, annotation)
 
 394         self.refresh(only_fields=['annotations'])
 
 396     def remove_annotation(self, annotation):
 
 398             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 400         if isinstance(annotation, TaskAnnotation):
 
 401             annotation = annotation['description']
 
 403         self.backend.denotate_task(self, annotation)
 
 404         self.refresh(only_fields=['annotations'])
 
 406     def refresh(self, only_fields=None, after_save=False):
 
 407         # Raise error when trying to refresh a task that has not been saved
 
 409             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 411         new_data = self.backend.refresh_task(self, after_save=after_save)
 
 415                 [(k, new_data.get(k)) for k in only_fields])
 
 416             self._update_data(to_update, update_original=True)
 
 418             self._load_data(new_data)
 
 421 class TaskQuerySet(object):
 
 423     Represents a lazy lookup for a task objects.
 
 426     def __init__(self, backend, filter_obj=None):
 
 427         self.backend = backend
 
 428         self._result_cache = None
 
 429         self.filter_obj = filter_obj or self.backend.filter_class(backend)
 
 431     def __deepcopy__(self, memo):
 
 433         Deep copy of a QuerySet doesn't populate the cache
 
 435         obj = self.__class__(backend=self.backend)
 
 436         for k, v in self.__dict__.items():
 
 437             if k in ('_iter', '_result_cache'):
 
 438                 obj.__dict__[k] = None
 
 440                 obj.__dict__[k] = copy.deepcopy(v, memo)
 
 444         data = list(self[:REPR_OUTPUT_SIZE + 1])
 
 445         if len(data) > REPR_OUTPUT_SIZE:
 
 446             data[-1] = "...(remaining elements truncated)..."
 
 450         if self._result_cache is None:
 
 451             self._result_cache = list(self)
 
 452         return len(self._result_cache)
 
 455         if self._result_cache is None:
 
 456             self._result_cache = self._execute()
 
 457         return iter(self._result_cache)
 
 459     def __getitem__(self, k):
 
 460         if self._result_cache is None:
 
 461             self._result_cache = list(self)
 
 462         return self._result_cache.__getitem__(k)
 
 465         if self._result_cache is not None:
 
 466             return bool(self._result_cache)
 
 469         except StopIteration:
 
 473     def __nonzero__(self):
 
 474         return type(self).__bool__(self)
 
 476     def _clone(self, klass=None, **kwargs):
 
 478             klass = self.__class__
 
 479         filter_obj = self.filter_obj.clone()
 
 480         c = klass(backend=self.backend, filter_obj=filter_obj)
 
 481         c.__dict__.update(kwargs)
 
 486         Fetch the tasks which match the current filters.
 
 488         return self.backend.filter_tasks(self.filter_obj)
 
 492         Returns a new TaskQuerySet that is a copy of the current one.
 
 497         return self.filter(status=PENDING)
 
 500         return self.filter(status=COMPLETED)
 
 502     def filter(self, *args, **kwargs):
 
 504         Returns a new TaskQuerySet with the given filters added.
 
 506         clone = self._clone()
 
 508             clone.filter_obj.add_filter(f)
 
 509         for key, value in kwargs.items():
 
 510             clone.filter_obj.add_filter_param(key, value)
 
 513     def get(self, **kwargs):
 
 515         Performs the query and returns a single object matching the given
 
 518         clone = self.filter(**kwargs)
 
 521             return clone._result_cache[0]
 
 523             raise Task.DoesNotExist(
 
 524                 'Task matching query does not exist. '
 
 525                 'Lookup parameters were {0}'.format(kwargs))
 
 527             'get() returned more than one Task -- it returned {0}! '
 
 528             'Lookup parameters were {1}'.format(num, kwargs))