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
 
  13 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
 
  16 COMPLETED = 'completed'
 
  18 VERSION_2_1_0 = six.u('2.1.0')
 
  19 VERSION_2_2_0 = six.u('2.2.0')
 
  20 VERSION_2_3_0 = six.u('2.3.0')
 
  21 VERSION_2_4_0 = six.u('2.4.0')
 
  23 logger = logging.getLogger(__name__)
 
  24 local_zone = tzlocal.get_localzone()
 
  27 class TaskWarriorException(Exception):
 
  31 class SerializingObject(object):
 
  33     Common ancestor for TaskResource & TaskFilter, since they both
 
  34     need to serialize arguments.
 
  36     Serializing method should hold the following contract:
 
  37       - any empty value (meaning removal of the attribute)
 
  38         is deserialized into a empty string
 
  39       - None denotes a empty value for any attribute
 
  41     Deserializing method should hold the following contract:
 
  42       - None denotes a empty value for any attribute (however,
 
  43         this is here as a safeguard, TaskWarrior currently does
 
  44         not export empty-valued attributes) if the attribute
 
  45         is not iterable (e.g. list or set), in which case
 
  46         a empty iterable should be used.
 
  49     def _deserialize(self, key, value):
 
  50         hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
 
  51                                lambda x: x if x != '' else None)
 
  52         return hydrate_func(value)
 
  54     def _serialize(self, key, value):
 
  55         dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
 
  56                                  lambda x: x if x is not None else '')
 
  57         return dehydrate_func(value)
 
  59     def _normalize(self, key, value):
 
  61         Use normalize_<key> methods to normalize user input. Any user
 
  62         input will be normalized at the moment it is used as filter,
 
  63         or entered as a value of Task attribute.
 
  66         normalize_func = getattr(self, 'normalize_{0}'.format(key),
 
  69         return normalize_func(value)
 
  71     def timestamp_serializer(self, date):
 
  75         # Any serialized timestamp should be localized, we need to
 
  76         # convert to UTC before converting to string (DATE_FORMAT uses UTC)
 
  77         date = date.astimezone(pytz.utc)
 
  79         return date.strftime(DATE_FORMAT)
 
  81     def timestamp_deserializer(self, date_str):
 
  85         # Return timestamp localized in the local zone
 
  86         naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT)
 
  87         localized_timestamp = pytz.utc.localize(naive_timestamp)
 
  88         return localized_timestamp.astimezone(local_zone)
 
  90     def serialize_entry(self, value):
 
  91         return self.timestamp_serializer(value)
 
  93     def deserialize_entry(self, value):
 
  94         return self.timestamp_deserializer(value)
 
  96     def normalize_entry(self, value):
 
  97         return self.datetime_normalizer(value)
 
  99     def serialize_modified(self, value):
 
 100         return self.timestamp_serializer(value)
 
 102     def deserialize_modified(self, value):
 
 103         return self.timestamp_deserializer(value)
 
 105     def normalize_modified(self, value):
 
 106         return self.datetime_normalizer(value)
 
 108     def serialize_due(self, value):
 
 109         return self.timestamp_serializer(value)
 
 111     def deserialize_due(self, value):
 
 112         return self.timestamp_deserializer(value)
 
 114     def normalize_due(self, value):
 
 115         return self.datetime_normalizer(value)
 
 117     def serialize_scheduled(self, value):
 
 118         return self.timestamp_serializer(value)
 
 120     def deserialize_scheduled(self, value):
 
 121         return self.timestamp_deserializer(value)
 
 123     def normalize_scheduled(self, value):
 
 124         return self.datetime_normalizer(value)
 
 126     def serialize_until(self, value):
 
 127         return self.timestamp_serializer(value)
 
 129     def deserialize_until(self, value):
 
 130         return self.timestamp_deserializer(value)
 
 132     def normalize_until(self, value):
 
 133         return self.datetime_normalizer(value)
 
 135     def serialize_wait(self, value):
 
 136         return self.timestamp_serializer(value)
 
 138     def deserialize_wait(self, value):
 
 139         return self.timestamp_deserializer(value)
 
 141     def normalize_wait(self, value):
 
 142         return self.datetime_normalizer(value)
 
 144     def serialize_annotations(self, value):
 
 145         value = value if value is not None else []
 
 147         # This may seem weird, but it's correct, we want to export
 
 148         # a list of dicts as serialized value
 
 149         serialized_annotations = [json.loads(annotation.export_data())
 
 150                                   for annotation in value]
 
 151         return serialized_annotations if serialized_annotations else ''
 
 153     def deserialize_annotations(self, data):
 
 154         return [TaskAnnotation(self, d) for d in data] if data else []
 
 156     def serialize_tags(self, tags):
 
 157         return ','.join(tags) if tags else ''
 
 159     def deserialize_tags(self, tags):
 
 160         if isinstance(tags, six.string_types):
 
 161             return tags.split(',') if tags else []
 
 164     def serialize_depends(self, value):
 
 165         # Return the list of uuids
 
 166         value = value if value is not None else set()
 
 167         return ','.join(task['uuid'] for task in value)
 
 169     def deserialize_depends(self, raw_uuids):
 
 170         raw_uuids = raw_uuids or ''  # Convert None to empty string
 
 171         uuids = raw_uuids.split(',')
 
 172         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
 
 174     def datetime_normalizer(self, value):
 
 176         Normalizes date/datetime value (considered to come from user input)
 
 177         to localized datetime value. Following conversions happen:
 
 179         naive date -> localized datetime with the same date, and time=midnight
 
 180         naive datetime -> localized datetime with the same value
 
 181         localized datetime -> localized datetime (no conversion)
 
 184         if (isinstance(value, datetime.date)
 
 185             and not isinstance(value, datetime.datetime)):
 
 186             # Convert to local midnight
 
 187             value_full = datetime.datetime.combine(value, datetime.time.min)
 
 188             localized = local_zone.localize(value_full)
 
 189         elif isinstance(value, datetime.datetime) and value.tzinfo is None:
 
 190             # Convert to localized datetime object
 
 191             localized = local_zone.localize(value)
 
 193             # If the value is already localized, there is no need to change
 
 194             # time zone at this point. Also None is a valid value too.
 
 199     def normalize_uuid(self, value):
 
 201         if not isinstance(value, six.text_type) or value == '':
 
 202             raise ValueError("UUID must be a valid non-empty string.")
 
 207 class TaskResource(SerializingObject):
 
 208     read_only_fields = []
 
 210     def _load_data(self, data):
 
 211         self._data = dict((key, self._deserialize(key, value))
 
 212                           for key, value in data.items())
 
 213         # We need to use a copy for original data, so that changes
 
 214         # are not propagated.
 
 215         self._original_data = copy.deepcopy(self._data)
 
 217     def _update_data(self, data, update_original=False):
 
 219         Low level update of the internal _data dict. Data which are coming as
 
 220         updates should already be serialized. If update_original is True, the
 
 221         original_data dict is updated as well.
 
 223         self._data.update(dict((key, self._deserialize(key, value))
 
 224                                for key, value in data.items()))
 
 227             self._original_data = copy.deepcopy(self._data)
 
 230     def __getitem__(self, key):
 
 231         # This is a workaround to make TaskResource non-iterable
 
 232         # over simple index-based iteration
 
 239         if key not in self._data:
 
 240             self._data[key] = self._deserialize(key, None)
 
 242         return self._data.get(key)
 
 244     def __setitem__(self, key, value):
 
 245         if key in self.read_only_fields:
 
 246             raise RuntimeError('Field \'%s\' is read-only' % key)
 
 248         # Normalize the user input before saving it
 
 249         value = self._normalize(key, value)
 
 250         self._data[key] = value
 
 253         s = six.text_type(self.__unicode__())
 
 255             s = s.encode('utf-8')
 
 261     def export_data(self):
 
 263         Exports current data contained in the Task as JSON
 
 266         # We need to remove spaces for TW-1504, use custom separators
 
 267         data_tuples = ((key, self._serialize(key, value))
 
 268                        for key, value in six.iteritems(self._data))
 
 270         # Empty string denotes empty serialized value, we do not want
 
 271         # to pass that to TaskWarrior.
 
 272         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
 
 273         data = dict(data_tuples)
 
 274         return json.dumps(data, separators=(',',':'))
 
 277     def _modified_fields(self):
 
 278         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
 
 279         for key in writable_fields:
 
 280             new_value = self._data.get(key)
 
 281             old_value = self._original_data.get(key)
 
 283             # Make sure not to mark data removal as modified field if the
 
 284             # field originally had some empty value
 
 285             if key in self._data and not new_value and not old_value:
 
 288             if new_value != old_value:
 
 293         return bool(list(self._modified_fields))
 
 296 class TaskAnnotation(TaskResource):
 
 297     read_only_fields = ['entry', 'description']
 
 299     def __init__(self, task, data={}):
 
 301         self._load_data(data)
 
 304         self.task.remove_annotation(self)
 
 306     def __unicode__(self):
 
 307         return self['description']
 
 309     def __eq__(self, other):
 
 310         # consider 2 annotations equal if they belong to the same task, and
 
 311         # their data dics are the same
 
 312         return self.task == other.task and self._data == other._data
 
 314     __repr__ = __unicode__
 
 317 class Task(TaskResource):
 
 318     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
 320     class DoesNotExist(Exception):
 
 323     class CompletedTask(Exception):
 
 325         Raised when the operation cannot be performed on the completed task.
 
 329     class DeletedTask(Exception):
 
 331         Raised when the operation cannot be performed on the deleted task.
 
 335     class NotSaved(Exception):
 
 337         Raised when the operation cannot be performed on the task, because
 
 338         it has not been saved to TaskWarrior yet.
 
 343     def from_input(cls, input_file=sys.stdin, modify=None):
 
 345         Creates a Task object, directly from the stdin, by reading one line.
 
 346         If modify=True, two lines are used, first line interpreted as the
 
 347         original state of the Task object, and second line as its new,
 
 348         modified value. This is consistent with the TaskWarrior's hook
 
 351         Object created by this method should not be saved, deleted
 
 352         or refreshed, as t could create a infinite loop. For this
 
 353         reason, TaskWarrior instance is set to None.
 
 355         Input_file argument can be used to specify the input file,
 
 356         but defaults to sys.stdin.
 
 359         # TaskWarrior instance is set to None
 
 362         # Detect the hook type if not given directly
 
 363         name = os.path.basename(sys.argv[0])
 
 364         modify = name.startswith('on-modify') if modify is None else modify
 
 366         # Load the data from the input
 
 367         task._load_data(json.loads(input_file.readline().strip()))
 
 369         # If this is a on-modify event, we are provided with additional
 
 370         # line of input, which provides updated data
 
 372             task._update_data(json.loads(input_file.readline().strip()))
 
 376     def __init__(self, warrior, **kwargs):
 
 377         self.warrior = warrior
 
 379         # Check that user is not able to set read-only value in __init__
 
 380         for key in kwargs.keys():
 
 381             if key in self.read_only_fields:
 
 382                 raise RuntimeError('Field \'%s\' is read-only' % key)
 
 384         # We serialize the data in kwargs so that users of the library
 
 385         # do not have to pass different data formats via __setitem__ and
 
 386         # __init__ methods, that would be confusing
 
 388         # Rather unfortunate syntax due to python2.6 comaptiblity
 
 389         self._data = dict((key, self._normalize(key, value))
 
 390                           for (key, value) in six.iteritems(kwargs))
 
 391         self._original_data = copy.deepcopy(self._data)
 
 393     def __unicode__(self):
 
 394         return self['description']
 
 396     def __eq__(self, other):
 
 397         if self['uuid'] and other['uuid']:
 
 398             # For saved Tasks, just define equality by equality of uuids
 
 399             return self['uuid'] == other['uuid']
 
 401             # If the tasks are not saved, compare the actual instances
 
 402             return id(self) == id(other)
 
 407             # For saved Tasks, just define equality by equality of uuids
 
 408             return self['uuid'].__hash__()
 
 410             # If the tasks are not saved, return hash of instance id
 
 411             return id(self).__hash__()
 
 415         return self['status'] == six.text_type('completed')
 
 419         return self['status'] == six.text_type('deleted')
 
 423         return self['status'] == six.text_type('waiting')
 
 427         return self['status'] == six.text_type('pending')
 
 431         return self['uuid'] is not None or self['id'] is not None
 
 433     def serialize_depends(self, cur_dependencies):
 
 434         # Check that all the tasks are saved
 
 435         for task in (cur_dependencies or set()):
 
 437                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 438                                     'it can be set as dependency.' % task)
 
 440         return super(Task, self).serialize_depends(cur_dependencies)
 
 442     def format_depends(self):
 
 443         # We need to generate added and removed dependencies list,
 
 444         # since Taskwarrior does not accept redefining dependencies.
 
 446         # This cannot be part of serialize_depends, since we need
 
 447         # to keep a list of all depedencies in the _data dictionary,
 
 448         # not just currently added/removed ones
 
 450         old_dependencies = self._original_data.get('depends', set())
 
 452         added = self['depends'] - old_dependencies
 
 453         removed = old_dependencies - self['depends']
 
 455         # Removed dependencies need to be prefixed with '-'
 
 456         return 'depends:' + ','.join(
 
 457                 [t['uuid'] for t in added] +
 
 458                 ['-' + t['uuid'] for t in removed]
 
 461     def format_description(self):
 
 462         # Task version older than 2.4.0 ignores first word of the
 
 463         # task description if description: prefix is used
 
 464         if self.warrior.version < VERSION_2_4_0:
 
 465             return self._data['description']
 
 467             return "description:'{0}'".format(self._data['description'] or '')
 
 471             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
 473         # Refresh the status, and raise exception if the task is deleted
 
 474         self.refresh(only_fields=['status'])
 
 477             raise Task.DeletedTask("Task was already deleted")
 
 479         self.warrior.execute_command([self['uuid'], 'delete'])
 
 481         # Refresh the status again, so that we have updated info stored
 
 482         self.refresh(only_fields=['status'])
 
 487             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
 489         # Refresh, and raise exception if task is already completed/deleted
 
 490         self.refresh(only_fields=['status'])
 
 493             raise Task.CompletedTask("Cannot complete a completed task")
 
 495             raise Task.DeletedTask("Deleted task cannot be completed")
 
 497         self.warrior.execute_command([self['uuid'], 'done'])
 
 499         # Refresh the status again, so that we have updated info stored
 
 500         self.refresh(only_fields=['status'])
 
 503         if self.saved and not self.modified:
 
 506         args = [self['uuid'], 'modify'] if self.saved else ['add']
 
 507         args.extend(self._get_modified_fields_as_args())
 
 508         output = self.warrior.execute_command(args)
 
 510         # Parse out the new ID, if the task is being added for the first time
 
 512             id_lines = [l for l in output if l.startswith('Created task ')]
 
 514             # Complain loudly if it seems that more tasks were created
 
 516             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
 
 517                 raise TaskWarriorException("Unexpected output when creating "
 
 518                                            "task: %s" % '\n'.join(id_lines))
 
 520             # Circumvent the ID storage, since ID is considered read-only
 
 521             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
 
 523         # Refreshing is very important here, as not only modification time
 
 524         # is updated, but arbitrary attribute may have changed due hooks
 
 525         # altering the data before saving
 
 528     def add_annotation(self, annotation):
 
 530             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 532         args = [self['uuid'], 'annotate', annotation]
 
 533         self.warrior.execute_command(args)
 
 534         self.refresh(only_fields=['annotations'])
 
 536     def remove_annotation(self, annotation):
 
 538             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 540         if isinstance(annotation, TaskAnnotation):
 
 541             annotation = annotation['description']
 
 542         args = [self['uuid'], 'denotate', annotation]
 
 543         self.warrior.execute_command(args)
 
 544         self.refresh(only_fields=['annotations'])
 
 546     def _get_modified_fields_as_args(self):
 
 549         def add_field(field):
 
 550             # Add the output of format_field method to args list (defaults to
 
 552             serialized_value = self._serialize(field, self._data[field])
 
 554             # Empty values should not be enclosed in quotation marks, see
 
 556             if serialized_value is '':
 
 557                 escaped_serialized_value = ''
 
 559                 escaped_serialized_value = "'{0}'".format(serialized_value)
 
 561             format_default = lambda: "{0}:{1}".format(field,
 
 562                                                       escaped_serialized_value)
 
 564             format_func = getattr(self, 'format_{0}'.format(field),
 
 567             args.append(format_func())
 
 569         # If we're modifying saved task, simply pass on all modified fields
 
 571             for field in self._modified_fields:
 
 573         # For new tasks, pass all fields that make sense
 
 575             for field in self._data.keys():
 
 576                 if field in self.read_only_fields:
 
 582     def refresh(self, only_fields=[]):
 
 583         # Raise error when trying to refresh a task that has not been saved
 
 585             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 587         # We need to use ID as backup for uuid here for the refreshes
 
 588         # of newly saved tasks. Any other place in the code is fine
 
 589         # with using UUID only.
 
 590         args = [self['uuid'] or self['id'], 'export']
 
 591         new_data = json.loads(self.warrior.execute_command(args)[0])
 
 594                 [(k, new_data.get(k)) for k in only_fields])
 
 595             self._update_data(to_update, update_original=True)
 
 597             self._load_data(new_data)
 
 599 class TaskFilter(SerializingObject):
 
 601     A set of parameters to filter the task list with.
 
 604     def __init__(self, filter_params=[]):
 
 605         self.filter_params = filter_params
 
 607     def add_filter(self, filter_str):
 
 608         self.filter_params.append(filter_str)
 
 610     def add_filter_param(self, key, value):
 
 611         key = key.replace('__', '.')
 
 613         # Replace the value with empty string, since that is the
 
 614         # convention in TW for empty values
 
 615         attribute_key = key.split('.')[0]
 
 617         # Since this is user input, we need to normalize before we serialize
 
 618         value = self._normalize(key, value)
 
 619         value = self._serialize(attribute_key, value)
 
 621         # If we are filtering by uuid:, do not use uuid keyword
 
 624             self.filter_params.insert(0, value)
 
 626             # Surround value with aphostrophes unless it's a empty string
 
 627             value = "'%s'" % value if value else ''
 
 629             # We enforce equality match by using 'is' (or 'none') modifier
 
 630             # Without using this syntax, filter fails due to TW-1479
 
 631             modifier = '.is' if value else '.none'
 
 632             key = key + modifier if '.' not in key else key
 
 634             self.filter_params.append("{0}:{1}".format(key, value))
 
 636     def get_filter_params(self):
 
 637         return [f for f in self.filter_params if f]
 
 641         c.filter_params = list(self.filter_params)
 
 645 class TaskQuerySet(object):
 
 647     Represents a lazy lookup for a task objects.
 
 650     def __init__(self, warrior=None, filter_obj=None):
 
 651         self.warrior = warrior
 
 652         self._result_cache = None
 
 653         self.filter_obj = filter_obj or TaskFilter()
 
 655     def __deepcopy__(self, memo):
 
 657         Deep copy of a QuerySet doesn't populate the cache
 
 659         obj = self.__class__()
 
 660         for k, v in self.__dict__.items():
 
 661             if k in ('_iter', '_result_cache'):
 
 662                 obj.__dict__[k] = None
 
 664                 obj.__dict__[k] = copy.deepcopy(v, memo)
 
 668         data = list(self[:REPR_OUTPUT_SIZE + 1])
 
 669         if len(data) > REPR_OUTPUT_SIZE:
 
 670             data[-1] = "...(remaining elements truncated)..."
 
 674         if self._result_cache is None:
 
 675             self._result_cache = list(self)
 
 676         return len(self._result_cache)
 
 679         if self._result_cache is None:
 
 680             self._result_cache = self._execute()
 
 681         return iter(self._result_cache)
 
 683     def __getitem__(self, k):
 
 684         if self._result_cache is None:
 
 685             self._result_cache = list(self)
 
 686         return self._result_cache.__getitem__(k)
 
 689         if self._result_cache is not None:
 
 690             return bool(self._result_cache)
 
 693         except StopIteration:
 
 697     def __nonzero__(self):
 
 698         return type(self).__bool__(self)
 
 700     def _clone(self, klass=None, **kwargs):
 
 702             klass = self.__class__
 
 703         filter_obj = self.filter_obj.clone()
 
 704         c = klass(warrior=self.warrior, filter_obj=filter_obj)
 
 705         c.__dict__.update(kwargs)
 
 710         Fetch the tasks which match the current filters.
 
 712         return self.warrior.filter_tasks(self.filter_obj)
 
 716         Returns a new TaskQuerySet that is a copy of the current one.
 
 721         return self.filter(status=PENDING)
 
 724         return self.filter(status=COMPLETED)
 
 726     def filter(self, *args, **kwargs):
 
 728         Returns a new TaskQuerySet with the given filters added.
 
 730         clone = self._clone()
 
 732             clone.filter_obj.add_filter(f)
 
 733         for key, value in kwargs.items():
 
 734             clone.filter_obj.add_filter_param(key, value)
 
 737     def get(self, **kwargs):
 
 739         Performs the query and returns a single object matching the given
 
 742         clone = self.filter(**kwargs)
 
 745             return clone._result_cache[0]
 
 747             raise Task.DoesNotExist(
 
 748                 'Task matching query does not exist. '
 
 749                 'Lookup parameters were {0}'.format(kwargs))
 
 751             'get() returned more than one Task -- it returned {0}! '
 
 752             'Lookup parameters were {1}'.format(num, kwargs))
 
 755 class TaskWarrior(object):
 
 756     def __init__(self, data_location='~/.task', create=True):
 
 757         data_location = os.path.expanduser(data_location)
 
 758         if create and not os.path.exists(data_location):
 
 759             os.makedirs(data_location)
 
 761             'data.location': os.path.expanduser(data_location),
 
 762             'confirmation': 'no',
 
 763             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
 
 764             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
 
 766         self.tasks = TaskQuerySet(self)
 
 767         self.version = self._get_version()
 
 769     def _get_command_args(self, args, config_override={}):
 
 770         command_args = ['task', 'rc:/']
 
 771         config = self.config.copy()
 
 772         config.update(config_override)
 
 773         for item in config.items():
 
 774             command_args.append('rc.{0}={1}'.format(*item))
 
 775         command_args.extend(map(str, args))
 
 778     def _get_version(self):
 
 779         p = subprocess.Popen(
 
 780                 ['task', '--version'],
 
 781                 stdout=subprocess.PIPE,
 
 782                 stderr=subprocess.PIPE)
 
 783         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 784         return stdout.strip('\n')
 
 786     def execute_command(self, args, config_override={}, allow_failure=True):
 
 787         command_args = self._get_command_args(
 
 788             args, config_override=config_override)
 
 789         logger.debug(' '.join(command_args))
 
 790         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
 
 791                              stderr=subprocess.PIPE)
 
 792         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 793         if p.returncode and allow_failure:
 
 795                 error_msg = stderr.strip().splitlines()[-1]
 
 797                 error_msg = stdout.strip()
 
 798             raise TaskWarriorException(error_msg)
 
 799         return stdout.strip().split('\n')
 
 801     def enforce_recurrence(self):
 
 802         # Run arbitrary report command which will trigger generation
 
 803         # of recurrent tasks.
 
 804         # TODO: Make a version dependant enforcement once
 
 806         self.execute_command(['next'], allow_failure=False)
 
 808     def filter_tasks(self, filter_obj):
 
 809         self.enforce_recurrence()
 
 810         args = ['export', '--'] + filter_obj.get_filter_params()
 
 812         for line in self.execute_command(args):
 
 814                 data = line.strip(',')
 
 816                     filtered_task = Task(self)
 
 817                     filtered_task._load_data(json.loads(data))
 
 818                     tasks.append(filtered_task)
 
 820                     raise TaskWarriorException('Invalid JSON: %s' % data)
 
 823     def merge_with(self, path, push=False):
 
 824         path = path.rstrip('/') + '/'
 
 825         self.execute_command(['merge', path], config_override={
 
 826             'merge.autopush': 'yes' if push else 'no',
 
 830         self.execute_command(['undo'])