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
 
  11 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
 
  14 COMPLETED = 'completed'
 
  16 VERSION_2_1_0 = six.u('2.1.0')
 
  17 VERSION_2_2_0 = six.u('2.2.0')
 
  18 VERSION_2_3_0 = six.u('2.3.0')
 
  19 VERSION_2_4_0 = six.u('2.4.0')
 
  21 logger = logging.getLogger(__name__)
 
  24 class TaskWarriorException(Exception):
 
  28 class SerializingObject(object):
 
  30     Common ancestor for TaskResource & TaskFilter, since they both
 
  31     need to serialize arguments.
 
  34     def _deserialize(self, key, value):
 
  35         hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
 
  36                                lambda x: x if x != '' else None)
 
  37         return hydrate_func(value)
 
  39     def _serialize(self, key, value):
 
  40         dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
 
  41                                  lambda x: x if x is not None else '')
 
  42         return dehydrate_func(value)
 
  44     def timestamp_serializer(self, date):
 
  47         return date.strftime(DATE_FORMAT)
 
  49     def timestamp_deserializer(self, date_str):
 
  52         return datetime.datetime.strptime(date_str, DATE_FORMAT)
 
  54     def serialize_entry(self, value):
 
  55         return self.timestamp_serializer(value)
 
  57     def deserialize_entry(self, value):
 
  58         return self.timestamp_deserializer(value)
 
  60     def serialize_modified(self, value):
 
  61         return self.timestamp_serializer(value)
 
  63     def deserialize_modified(self, value):
 
  64         return self.timestamp_deserializer(value)
 
  66     def serialize_due(self, value):
 
  67         return self.timestamp_serializer(value)
 
  69     def deserialize_due(self, value):
 
  70         return self.timestamp_deserializer(value)
 
  72     def serialize_scheduled(self, value):
 
  73         return self.timestamp_serializer(value)
 
  75     def deserialize_scheduled(self, value):
 
  76         return self.timestamp_deserializer(value)
 
  78     def serialize_until(self, value):
 
  79         return self.timestamp_serializer(value)
 
  81     def deserialize_until(self, value):
 
  82         return self.timestamp_deserializer(value)
 
  84     def serialize_wait(self, value):
 
  85         return self.timestamp_serializer(value)
 
  87     def deserialize_wait(self, value):
 
  88         return self.timestamp_deserializer(value)
 
  90     def deserialize_annotations(self, data):
 
  91         return [TaskAnnotation(self, d) for d in data] if data else []
 
  93     def serialize_tags(self, tags):
 
  94         return ','.join(tags) if tags else ''
 
  96     def deserialize_tags(self, tags):
 
  97         if isinstance(tags, six.string_types):
 
  98             return tags.split(',') if tags else []
 
 101     def serialize_depends(self, cur_dependencies):
 
 102         # Return the list of uuids
 
 103         return ','.join(task['uuid'] for task in cur_dependencies)
 
 105     def deserialize_depends(self, raw_uuids):
 
 106         raw_uuids = raw_uuids or ''  # Convert None to empty string
 
 107         uuids = raw_uuids.split(',')
 
 108         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
 
 111 class TaskResource(SerializingObject):
 
 112     read_only_fields = []
 
 114     def _load_data(self, data):
 
 115         self._data = dict((key, self._deserialize(key, value))
 
 116                           for key, value in data.items())
 
 117         # We need to use a copy for original data, so that changes
 
 118         # are not propagated.
 
 119         self._original_data = copy.deepcopy(self._data)
 
 121     def _update_data(self, data, update_original=False):
 
 123         Low level update of the internal _data dict. Data which are coming as
 
 124         updates should already be serialized. If update_original is True, the
 
 125         original_data dict is updated as well.
 
 127         self._data.update(dict((key, self._deserialize(key, value))
 
 128                                for key, value in data.items()))
 
 131             self._original_data = copy.deepcopy(self._data)
 
 134     def __getitem__(self, key):
 
 135         # This is a workaround to make TaskResource non-iterable
 
 136         # over simple index-based iteration
 
 143         if key not in self._data:
 
 144             self._data[key] = self._deserialize(key, None)
 
 146         return self._data.get(key)
 
 148     def __setitem__(self, key, value):
 
 149         if key in self.read_only_fields:
 
 150             raise RuntimeError('Field \'%s\' is read-only' % key)
 
 151         self._data[key] = value
 
 154         s = six.text_type(self.__unicode__())
 
 156             s = s.encode('utf-8')
 
 163 class TaskAnnotation(TaskResource):
 
 164     read_only_fields = ['entry', 'description']
 
 166     def __init__(self, task, data={}):
 
 168         self._load_data(data)
 
 171         self.task.remove_annotation(self)
 
 173     def __unicode__(self):
 
 174         return self['description']
 
 176     def __eq__(self, other):
 
 177         # consider 2 annotations equal if they belong to the same task, and
 
 178         # their data dics are the same
 
 179         return self.task == other.task and self._data == other._data
 
 181     __repr__ = __unicode__
 
 184 class Task(TaskResource):
 
 185     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
 187     class DoesNotExist(Exception):
 
 190     class CompletedTask(Exception):
 
 192         Raised when the operation cannot be performed on the completed task.
 
 196     class DeletedTask(Exception):
 
 198         Raised when the operation cannot be performed on the deleted 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):
 
 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         # TaskWarrior instance is set to None
 
 229         # Detect the hook type if not given directly
 
 230         name = os.path.basename(sys.argv[0])
 
 231         modify = name.startswith('on-modify') if modify is None else modify
 
 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()))
 
 243     def __init__(self, warrior, **kwargs):
 
 244         self.warrior = warrior
 
 246         # Check that user is not able to set read-only value in __init__
 
 247         for key in kwargs.keys():
 
 248             if key in self.read_only_fields:
 
 249                 raise RuntimeError('Field \'%s\' is read-only' % key)
 
 251         # We serialize the data in kwargs so that users of the library
 
 252         # do not have to pass different data formats via __setitem__ and
 
 253         # __init__ methods, that would be confusing
 
 255         # Rather unfortunate syntax due to python2.6 comaptiblity
 
 256         self._load_data(dict((key, self._serialize(key, value))
 
 257                         for (key, value) in six.iteritems(kwargs)))
 
 259     def __unicode__(self):
 
 260         return self['description']
 
 262     def __eq__(self, other):
 
 263         if self['uuid'] and other['uuid']:
 
 264             # For saved Tasks, just define equality by equality of uuids
 
 265             return self['uuid'] == other['uuid']
 
 267             # If the tasks are not saved, compare the actual instances
 
 268             return id(self) == id(other)
 
 273             # For saved Tasks, just define equality by equality of uuids
 
 274             return self['uuid'].__hash__()
 
 276             # If the tasks are not saved, return hash of instance id
 
 277             return id(self).__hash__()
 
 280     def _modified_fields(self):
 
 281         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
 
 282         for key in writable_fields:
 
 283             new_value = self._data.get(key)
 
 284             old_value = self._original_data.get(key)
 
 286             # Make sure not to mark data removal as modified field if the
 
 287             # field originally had some empty value
 
 288             if key in self._data and not new_value and not old_value:
 
 291             if new_value != old_value:
 
 296         return bool(list(self._modified_fields))
 
 300         return self['status'] == six.text_type('completed')
 
 304         return self['status'] == six.text_type('deleted')
 
 308         return self['status'] == six.text_type('waiting')
 
 312         return self['status'] == six.text_type('pending')
 
 316         return self['uuid'] is not None or self['id'] is not None
 
 318     def serialize_depends(self, cur_dependencies):
 
 319         # Check that all the tasks are saved
 
 320         for task in cur_dependencies:
 
 322                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 323                                     'it can be set as dependency.' % task)
 
 325         return super(Task, self).serialize_depends(cur_dependencies)
 
 327     def format_depends(self):
 
 328         # We need to generate added and removed dependencies list,
 
 329         # since Taskwarrior does not accept redefining dependencies.
 
 331         # This cannot be part of serialize_depends, since we need
 
 332         # to keep a list of all depedencies in the _data dictionary,
 
 333         # not just currently added/removed ones
 
 335         old_dependencies = self._original_data.get('depends', set())
 
 337         added = self['depends'] - old_dependencies
 
 338         removed = old_dependencies - self['depends']
 
 340         # Removed dependencies need to be prefixed with '-'
 
 341         return 'depends:' + ','.join(
 
 342                 [t['uuid'] for t in added] +
 
 343                 ['-' + t['uuid'] for t in removed]
 
 346     def format_description(self):
 
 347         # Task version older than 2.4.0 ignores first word of the
 
 348         # task description if description: prefix is used
 
 349         if self.warrior.version < VERSION_2_4_0:
 
 350             return self._data['description']
 
 352             return "description:'{0}'".format(self._data['description'] or '')
 
 356             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
 358         # Refresh the status, and raise exception if the task is deleted
 
 359         self.refresh(only_fields=['status'])
 
 362             raise Task.DeletedTask("Task was already deleted")
 
 364         self.warrior.execute_command([self['uuid'], 'delete'])
 
 366         # Refresh the status again, so that we have updated info stored
 
 367         self.refresh(only_fields=['status'])
 
 372             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
 374         # Refresh, and raise exception if task is already completed/deleted
 
 375         self.refresh(only_fields=['status'])
 
 378             raise Task.CompletedTask("Cannot complete a completed task")
 
 380             raise Task.DeletedTask("Deleted task cannot be completed")
 
 382         self.warrior.execute_command([self['uuid'], 'done'])
 
 384         # Refresh the status again, so that we have updated info stored
 
 385         self.refresh(only_fields=['status'])
 
 388         if self.saved and not self.modified:
 
 391         args = [self['uuid'], 'modify'] if self.saved else ['add']
 
 392         args.extend(self._get_modified_fields_as_args())
 
 393         output = self.warrior.execute_command(args)
 
 395         # Parse out the new ID, if the task is being added for the first time
 
 397             id_lines = [l for l in output if l.startswith('Created task ')]
 
 399             # Complain loudly if it seems that more tasks were created
 
 401             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
 
 402                 raise TaskWarriorException("Unexpected output when creating "
 
 403                                            "task: %s" % '\n'.join(id_lines))
 
 405             # Circumvent the ID storage, since ID is considered read-only
 
 406             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
 
 408         # Refreshing is very important here, as not only modification time
 
 409         # is updated, but arbitrary attribute may have changed due hooks
 
 410         # altering the data before saving
 
 413     def add_annotation(self, annotation):
 
 415             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 417         args = [self['uuid'], 'annotate', annotation]
 
 418         self.warrior.execute_command(args)
 
 419         self.refresh(only_fields=['annotations'])
 
 421     def remove_annotation(self, annotation):
 
 423             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 425         if isinstance(annotation, TaskAnnotation):
 
 426             annotation = annotation['description']
 
 427         args = [self['uuid'], 'denotate', annotation]
 
 428         self.warrior.execute_command(args)
 
 429         self.refresh(only_fields=['annotations'])
 
 431     def _get_modified_fields_as_args(self):
 
 434         def add_field(field):
 
 435             # Add the output of format_field method to args list (defaults to
 
 437             serialized_value = self._serialize(field, self._data[field])
 
 439             # Empty values should not be enclosed in quotation marks, see
 
 441             if serialized_value is '':
 
 442                 escaped_serialized_value = ''
 
 444                 escaped_serialized_value = "'{0}'".format(serialized_value)
 
 446             format_default = lambda: "{0}:{1}".format(field,
 
 447                                                       escaped_serialized_value)
 
 449             format_func = getattr(self, 'format_{0}'.format(field),
 
 452             args.append(format_func())
 
 454         # If we're modifying saved task, simply pass on all modified fields
 
 456             for field in self._modified_fields:
 
 458         # For new tasks, pass all fields that make sense
 
 460             for field in self._data.keys():
 
 461                 if field in self.read_only_fields:
 
 467     def refresh(self, only_fields=[]):
 
 468         # Raise error when trying to refresh a task that has not been saved
 
 470             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 472         # We need to use ID as backup for uuid here for the refreshes
 
 473         # of newly saved tasks. Any other place in the code is fine
 
 474         # with using UUID only.
 
 475         args = [self['uuid'] or self['id'], 'export']
 
 476         new_data = json.loads(self.warrior.execute_command(args)[0])
 
 479                 [(k, new_data.get(k)) for k in only_fields])
 
 480             self._update_data(to_update, update_original=True)
 
 482             self._load_data(new_data)
 
 484     def export_data(self):
 
 486         Exports current data contained in the Task as JSON
 
 489         # We need to remove spaces for TW-1504, use custom separators
 
 490         data_tuples = ((key, self._serialize(key, value))
 
 491                        for key, value in six.iteritems(self._data))
 
 493         # Empty string denotes empty serialized value, we do not want
 
 494         # to pass that to TaskWarrior.
 
 495         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
 
 496         data = dict(data_tuples)
 
 497         return json.dumps(data, separators=(',',':'))
 
 499 class TaskFilter(SerializingObject):
 
 501     A set of parameters to filter the task list with.
 
 504     def __init__(self, filter_params=[]):
 
 505         self.filter_params = filter_params
 
 507     def add_filter(self, filter_str):
 
 508         self.filter_params.append(filter_str)
 
 510     def add_filter_param(self, key, value):
 
 511         key = key.replace('__', '.')
 
 513         # Replace the value with empty string, since that is the
 
 514         # convention in TW for empty values
 
 515         attribute_key = key.split('.')[0]
 
 516         value = self._serialize(attribute_key, value)
 
 518         # If we are filtering by uuid:, do not use uuid keyword
 
 521             self.filter_params.insert(0, value)
 
 523             # Surround value with aphostrophes unless it's a empty string
 
 524             value = "'%s'" % value if value else ''
 
 526             # We enforce equality match by using 'is' (or 'none') modifier
 
 527             # Without using this syntax, filter fails due to TW-1479
 
 528             modifier = '.is' if value else '.none'
 
 529             key = key + modifier if '.' not in key else key
 
 531             self.filter_params.append("{0}:{1}".format(key, value))
 
 533     def get_filter_params(self):
 
 534         return [f for f in self.filter_params if f]
 
 538         c.filter_params = list(self.filter_params)
 
 542 class TaskQuerySet(object):
 
 544     Represents a lazy lookup for a task objects.
 
 547     def __init__(self, warrior=None, filter_obj=None):
 
 548         self.warrior = warrior
 
 549         self._result_cache = None
 
 550         self.filter_obj = filter_obj or TaskFilter()
 
 552     def __deepcopy__(self, memo):
 
 554         Deep copy of a QuerySet doesn't populate the cache
 
 556         obj = self.__class__()
 
 557         for k, v in self.__dict__.items():
 
 558             if k in ('_iter', '_result_cache'):
 
 559                 obj.__dict__[k] = None
 
 561                 obj.__dict__[k] = copy.deepcopy(v, memo)
 
 565         data = list(self[:REPR_OUTPUT_SIZE + 1])
 
 566         if len(data) > REPR_OUTPUT_SIZE:
 
 567             data[-1] = "...(remaining elements truncated)..."
 
 571         if self._result_cache is None:
 
 572             self._result_cache = list(self)
 
 573         return len(self._result_cache)
 
 576         if self._result_cache is None:
 
 577             self._result_cache = self._execute()
 
 578         return iter(self._result_cache)
 
 580     def __getitem__(self, k):
 
 581         if self._result_cache is None:
 
 582             self._result_cache = list(self)
 
 583         return self._result_cache.__getitem__(k)
 
 586         if self._result_cache is not None:
 
 587             return bool(self._result_cache)
 
 590         except StopIteration:
 
 594     def __nonzero__(self):
 
 595         return type(self).__bool__(self)
 
 597     def _clone(self, klass=None, **kwargs):
 
 599             klass = self.__class__
 
 600         filter_obj = self.filter_obj.clone()
 
 601         c = klass(warrior=self.warrior, filter_obj=filter_obj)
 
 602         c.__dict__.update(kwargs)
 
 607         Fetch the tasks which match the current filters.
 
 609         return self.warrior.filter_tasks(self.filter_obj)
 
 613         Returns a new TaskQuerySet that is a copy of the current one.
 
 618         return self.filter(status=PENDING)
 
 621         return self.filter(status=COMPLETED)
 
 623     def filter(self, *args, **kwargs):
 
 625         Returns a new TaskQuerySet with the given filters added.
 
 627         clone = self._clone()
 
 629             clone.filter_obj.add_filter(f)
 
 630         for key, value in kwargs.items():
 
 631             clone.filter_obj.add_filter_param(key, value)
 
 634     def get(self, **kwargs):
 
 636         Performs the query and returns a single object matching the given
 
 639         clone = self.filter(**kwargs)
 
 642             return clone._result_cache[0]
 
 644             raise Task.DoesNotExist(
 
 645                 'Task matching query does not exist. '
 
 646                 'Lookup parameters were {0}'.format(kwargs))
 
 648             'get() returned more than one Task -- it returned {0}! '
 
 649             'Lookup parameters were {1}'.format(num, kwargs))
 
 652 class TaskWarrior(object):
 
 653     def __init__(self, data_location='~/.task', create=True):
 
 654         data_location = os.path.expanduser(data_location)
 
 655         if create and not os.path.exists(data_location):
 
 656             os.makedirs(data_location)
 
 658             'data.location': os.path.expanduser(data_location),
 
 659             'confirmation': 'no',
 
 660             'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
 
 662         self.tasks = TaskQuerySet(self)
 
 663         self.version = self._get_version()
 
 665     def _get_command_args(self, args, config_override={}):
 
 666         command_args = ['task', 'rc:/']
 
 667         config = self.config.copy()
 
 668         config.update(config_override)
 
 669         for item in config.items():
 
 670             command_args.append('rc.{0}={1}'.format(*item))
 
 671         command_args.extend(map(str, args))
 
 674     def _get_version(self):
 
 675         p = subprocess.Popen(
 
 676                 ['task', '--version'],
 
 677                 stdout=subprocess.PIPE,
 
 678                 stderr=subprocess.PIPE)
 
 679         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 680         return stdout.strip('\n')
 
 682     def execute_command(self, args, config_override={}):
 
 683         command_args = self._get_command_args(
 
 684             args, config_override=config_override)
 
 685         logger.debug(' '.join(command_args))
 
 686         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
 
 687                              stderr=subprocess.PIPE)
 
 688         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 691                 error_msg = stderr.strip().splitlines()[-1]
 
 693                 error_msg = stdout.strip()
 
 694             raise TaskWarriorException(error_msg)
 
 695         return stdout.strip().split('\n')
 
 697     def filter_tasks(self, filter_obj):
 
 698         args = ['export', '--'] + filter_obj.get_filter_params()
 
 700         for line in self.execute_command(args):
 
 702                 data = line.strip(',')
 
 704                     filtered_task = Task(self)
 
 705                     filtered_task._load_data(json.loads(data))
 
 706                     tasks.append(filtered_task)
 
 708                     raise TaskWarriorException('Invalid JSON: %s' % data)
 
 711     def merge_with(self, path, push=False):
 
 712         path = path.rstrip('/') + '/'
 
 713         self.execute_command(['merge', path], config_override={
 
 714             'merge.autopush': 'yes' if push else 'no',
 
 718         self.execute_command(['undo'])