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=False):
 
 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         # Load the data from the input
 
 230         task._load_data(json.loads(input_file.readline().strip()))
 
 232         # If this is a on-modify event, we are provided with additional
 
 233         # line of input, which provides updated data
 
 235             task._update_data(json.loads(input_file.readline().strip()))
 
 239     def __init__(self, warrior, **kwargs):
 
 240         self.warrior = warrior
 
 242         # Check that user is not able to set read-only value in __init__
 
 243         for key in kwargs.keys():
 
 244             if key in self.read_only_fields:
 
 245                 raise RuntimeError('Field \'%s\' is read-only' % key)
 
 247         # We serialize the data in kwargs so that users of the library
 
 248         # do not have to pass different data formats via __setitem__ and
 
 249         # __init__ methods, that would be confusing
 
 251         # Rather unfortunate syntax due to python2.6 comaptiblity
 
 252         self._load_data(dict((key, self._serialize(key, value))
 
 253                         for (key, value) in six.iteritems(kwargs)))
 
 255     def __unicode__(self):
 
 256         return self['description']
 
 258     def __eq__(self, other):
 
 259         if self['uuid'] and other['uuid']:
 
 260             # For saved Tasks, just define equality by equality of uuids
 
 261             return self['uuid'] == other['uuid']
 
 263             # If the tasks are not saved, compare the actual instances
 
 264             return id(self) == id(other)
 
 269             # For saved Tasks, just define equality by equality of uuids
 
 270             return self['uuid'].__hash__()
 
 272             # If the tasks are not saved, return hash of instance id
 
 273             return id(self).__hash__()
 
 276     def _modified_fields(self):
 
 277         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
 
 278         for key in writable_fields:
 
 279             if self._data.get(key) != self._original_data.get(key):
 
 284         return bool(list(self._modified_fields))
 
 288         return self['status'] == six.text_type('completed')
 
 292         return self['status'] == six.text_type('deleted')
 
 296         return self['status'] == six.text_type('waiting')
 
 300         return self['status'] == six.text_type('pending')
 
 304         return self['uuid'] is not None or self['id'] is not None
 
 306     def serialize_depends(self, cur_dependencies):
 
 307         # Check that all the tasks are saved
 
 308         for task in cur_dependencies:
 
 310                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 311                                     'it can be set as dependency.' % task)
 
 313         return super(Task, self).serialize_depends(cur_dependencies)
 
 315     def format_depends(self):
 
 316         # We need to generate added and removed dependencies list,
 
 317         # since Taskwarrior does not accept redefining dependencies.
 
 319         # This cannot be part of serialize_depends, since we need
 
 320         # to keep a list of all depedencies in the _data dictionary,
 
 321         # not just currently added/removed ones
 
 323         old_dependencies = self._original_data.get('depends', set())
 
 325         added = self['depends'] - old_dependencies
 
 326         removed = old_dependencies - self['depends']
 
 328         # Removed dependencies need to be prefixed with '-'
 
 329         return 'depends:' + ','.join(
 
 330                 [t['uuid'] for t in added] +
 
 331                 ['-' + t['uuid'] for t in removed]
 
 334     def format_description(self):
 
 335         # Task version older than 2.4.0 ignores first word of the
 
 336         # task description if description: prefix is used
 
 337         if self.warrior.version < VERSION_2_4_0:
 
 338             return self._data['description']
 
 340             return "description:'{0}'".format(self._data['description'] or '')
 
 344             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
 346         # Refresh the status, and raise exception if the task is deleted
 
 347         self.refresh(only_fields=['status'])
 
 350             raise Task.DeletedTask("Task was already deleted")
 
 352         self.warrior.execute_command([self['uuid'], 'delete'])
 
 354         # Refresh the status again, so that we have updated info stored
 
 355         self.refresh(only_fields=['status'])
 
 360             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
 362         # Refresh, and raise exception if task is already completed/deleted
 
 363         self.refresh(only_fields=['status'])
 
 366             raise Task.CompletedTask("Cannot complete a completed task")
 
 368             raise Task.DeletedTask("Deleted task cannot be completed")
 
 370         self.warrior.execute_command([self['uuid'], 'done'])
 
 372         # Refresh the status again, so that we have updated info stored
 
 373         self.refresh(only_fields=['status'])
 
 376         if self.saved and not self.modified:
 
 379         args = [self['uuid'], 'modify'] if self.saved else ['add']
 
 380         args.extend(self._get_modified_fields_as_args())
 
 381         output = self.warrior.execute_command(args)
 
 383         # Parse out the new ID, if the task is being added for the first time
 
 385             id_lines = [l for l in output if l.startswith('Created task ')]
 
 387             # Complain loudly if it seems that more tasks were created
 
 389             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
 
 390                 raise TaskWarriorException("Unexpected output when creating "
 
 391                                            "task: %s" % '\n'.join(id_lines))
 
 393             # Circumvent the ID storage, since ID is considered read-only
 
 394             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
 
 396         # Refreshing is very important here, as not only modification time
 
 397         # is updated, but arbitrary attribute may have changed due hooks
 
 398         # altering the data before saving
 
 401     def add_annotation(self, annotation):
 
 403             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 405         args = [self['uuid'], 'annotate', annotation]
 
 406         self.warrior.execute_command(args)
 
 407         self.refresh(only_fields=['annotations'])
 
 409     def remove_annotation(self, annotation):
 
 411             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 413         if isinstance(annotation, TaskAnnotation):
 
 414             annotation = annotation['description']
 
 415         args = [self['uuid'], 'denotate', annotation]
 
 416         self.warrior.execute_command(args)
 
 417         self.refresh(only_fields=['annotations'])
 
 419     def _get_modified_fields_as_args(self):
 
 422         def add_field(field):
 
 423             # Add the output of format_field method to args list (defaults to
 
 425             serialized_value = self._serialize(field, self._data[field]) or ''
 
 426             format_default = lambda: "{0}:{1}".format(
 
 428                 "'{0}'".format(serialized_value) if serialized_value else ''
 
 430             format_func = getattr(self, 'format_{0}'.format(field),
 
 432             args.append(format_func())
 
 434         # If we're modifying saved task, simply pass on all modified fields
 
 436             for field in self._modified_fields:
 
 438         # For new tasks, pass all fields that make sense
 
 440             for field in self._data.keys():
 
 441                 if field in self.read_only_fields:
 
 447     def refresh(self, only_fields=[]):
 
 448         # Raise error when trying to refresh a task that has not been saved
 
 450             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 452         # We need to use ID as backup for uuid here for the refreshes
 
 453         # of newly saved tasks. Any other place in the code is fine
 
 454         # with using UUID only.
 
 455         args = [self['uuid'] or self['id'], 'export']
 
 456         new_data = json.loads(self.warrior.execute_command(args)[0])
 
 459                 [(k, new_data.get(k)) for k in only_fields])
 
 460             self._update_data(to_update, update_original=True)
 
 462             self._load_data(new_data)
 
 464     def export_data(self):
 
 466         Exports current data contained in the Task as JSON
 
 469         # We need to remove spaces for TW-1504, use custom separators
 
 470         data = dict((key, self._serialize(key, value))
 
 471                     for key, value in six.iteritems(self._data))
 
 472         return json.dumps(data, separators=(',',':'))
 
 474 class TaskFilter(SerializingObject):
 
 476     A set of parameters to filter the task list with.
 
 479     def __init__(self, filter_params=[]):
 
 480         self.filter_params = filter_params
 
 482     def add_filter(self, filter_str):
 
 483         self.filter_params.append(filter_str)
 
 485     def add_filter_param(self, key, value):
 
 486         key = key.replace('__', '.')
 
 488         # Replace the value with empty string, since that is the
 
 489         # convention in TW for empty values
 
 490         attribute_key = key.split('.')[0]
 
 491         value = self._serialize(attribute_key, value)
 
 493         # If we are filtering by uuid:, do not use uuid keyword
 
 496             self.filter_params.insert(0, value)
 
 498             # Surround value with aphostrophes unless it's a empty string
 
 499             value = "'%s'" % value if value else ''
 
 501             # We enforce equality match by using 'is' (or 'none') modifier
 
 502             # Without using this syntax, filter fails due to TW-1479
 
 503             modifier = '.is' if value else '.none'
 
 504             key = key + modifier if '.' not in key else key
 
 506             self.filter_params.append("{0}:{1}".format(key, value))
 
 508     def get_filter_params(self):
 
 509         return [f for f in self.filter_params if f]
 
 513         c.filter_params = list(self.filter_params)
 
 517 class TaskQuerySet(object):
 
 519     Represents a lazy lookup for a task objects.
 
 522     def __init__(self, warrior=None, filter_obj=None):
 
 523         self.warrior = warrior
 
 524         self._result_cache = None
 
 525         self.filter_obj = filter_obj or TaskFilter()
 
 527     def __deepcopy__(self, memo):
 
 529         Deep copy of a QuerySet doesn't populate the cache
 
 531         obj = self.__class__()
 
 532         for k, v in self.__dict__.items():
 
 533             if k in ('_iter', '_result_cache'):
 
 534                 obj.__dict__[k] = None
 
 536                 obj.__dict__[k] = copy.deepcopy(v, memo)
 
 540         data = list(self[:REPR_OUTPUT_SIZE + 1])
 
 541         if len(data) > REPR_OUTPUT_SIZE:
 
 542             data[-1] = "...(remaining elements truncated)..."
 
 546         if self._result_cache is None:
 
 547             self._result_cache = list(self)
 
 548         return len(self._result_cache)
 
 551         if self._result_cache is None:
 
 552             self._result_cache = self._execute()
 
 553         return iter(self._result_cache)
 
 555     def __getitem__(self, k):
 
 556         if self._result_cache is None:
 
 557             self._result_cache = list(self)
 
 558         return self._result_cache.__getitem__(k)
 
 561         if self._result_cache is not None:
 
 562             return bool(self._result_cache)
 
 565         except StopIteration:
 
 569     def __nonzero__(self):
 
 570         return type(self).__bool__(self)
 
 572     def _clone(self, klass=None, **kwargs):
 
 574             klass = self.__class__
 
 575         filter_obj = self.filter_obj.clone()
 
 576         c = klass(warrior=self.warrior, filter_obj=filter_obj)
 
 577         c.__dict__.update(kwargs)
 
 582         Fetch the tasks which match the current filters.
 
 584         return self.warrior.filter_tasks(self.filter_obj)
 
 588         Returns a new TaskQuerySet that is a copy of the current one.
 
 593         return self.filter(status=PENDING)
 
 596         return self.filter(status=COMPLETED)
 
 598     def filter(self, *args, **kwargs):
 
 600         Returns a new TaskQuerySet with the given filters added.
 
 602         clone = self._clone()
 
 604             clone.filter_obj.add_filter(f)
 
 605         for key, value in kwargs.items():
 
 606             clone.filter_obj.add_filter_param(key, value)
 
 609     def get(self, **kwargs):
 
 611         Performs the query and returns a single object matching the given
 
 614         clone = self.filter(**kwargs)
 
 617             return clone._result_cache[0]
 
 619             raise Task.DoesNotExist(
 
 620                 'Task matching query does not exist. '
 
 621                 'Lookup parameters were {0}'.format(kwargs))
 
 623             'get() returned more than one Task -- it returned {0}! '
 
 624             'Lookup parameters were {1}'.format(num, kwargs))
 
 627 class TaskWarrior(object):
 
 628     def __init__(self, data_location='~/.task', create=True):
 
 629         data_location = os.path.expanduser(data_location)
 
 630         if create and not os.path.exists(data_location):
 
 631             os.makedirs(data_location)
 
 633             'data.location': os.path.expanduser(data_location),
 
 634             'confirmation': 'no',
 
 635             'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
 
 637         self.tasks = TaskQuerySet(self)
 
 638         self.version = self._get_version()
 
 640     def _get_command_args(self, args, config_override={}):
 
 641         command_args = ['task', 'rc:/']
 
 642         config = self.config.copy()
 
 643         config.update(config_override)
 
 644         for item in config.items():
 
 645             command_args.append('rc.{0}={1}'.format(*item))
 
 646         command_args.extend(map(str, args))
 
 649     def _get_version(self):
 
 650         p = subprocess.Popen(
 
 651                 ['task', '--version'],
 
 652                 stdout=subprocess.PIPE,
 
 653                 stderr=subprocess.PIPE)
 
 654         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 655         return stdout.strip('\n')
 
 657     def execute_command(self, args, config_override={}):
 
 658         command_args = self._get_command_args(
 
 659             args, config_override=config_override)
 
 660         logger.debug(' '.join(command_args))
 
 661         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
 
 662                              stderr=subprocess.PIPE)
 
 663         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 666                 error_msg = stderr.strip().splitlines()[-1]
 
 668                 error_msg = stdout.strip()
 
 669             raise TaskWarriorException(error_msg)
 
 670         return stdout.strip().split('\n')
 
 672     def filter_tasks(self, filter_obj):
 
 673         args = ['export', '--'] + filter_obj.get_filter_params()
 
 675         for line in self.execute_command(args):
 
 677                 data = line.strip(',')
 
 679                     filtered_task = Task(self)
 
 680                     filtered_task._load_data(json.loads(data))
 
 681                     tasks.append(filtered_task)
 
 683                     raise TaskWarriorException('Invalid JSON: %s' % data)
 
 686     def merge_with(self, path, push=False):
 
 687         path = path.rstrip('/') + '/'
 
 688         self.execute_command(['merge', path], config_override={
 
 689             'merge.autopush': 'yes' if push else 'no',
 
 693         self.execute_command(['undo'])