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'
14 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
17 COMPLETED = 'completed'
19 VERSION_2_1_0 = six.u('2.1.0')
20 VERSION_2_2_0 = six.u('2.2.0')
21 VERSION_2_3_0 = six.u('2.3.0')
22 VERSION_2_4_0 = six.u('2.4.0')
23 VERSION_2_4_1 = six.u('2.4.1')
24 VERSION_2_4_2 = six.u('2.4.2')
26 logger = logging.getLogger(__name__)
27 local_zone = tzlocal.get_localzone()
30 class TaskWarriorException(Exception):
34 class ReadOnlyDictView(object):
36 Provides simplified read-only view upon dict object.
39 def __init__(self, viewed_dict):
40 self.viewed_dict = viewed_dict
42 def __getitem__(self, key):
43 return copy.deepcopy(self.viewed_dict.__getitem__(key))
45 def __contains__(self, k):
46 return self.viewed_dict.__contains__(k)
49 for value in self.viewed_dict:
50 yield copy.deepcopy(value)
53 return len(self.viewed_dict)
55 def get(self, key, default=None):
56 return copy.deepcopy(self.viewed_dict.get(key, default))
59 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
62 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
65 class SerializingObject(object):
67 Common ancestor for TaskResource & TaskFilter, since they both
68 need to serialize arguments.
70 Serializing method should hold the following contract:
71 - any empty value (meaning removal of the attribute)
72 is deserialized into a empty string
73 - None denotes a empty value for any attribute
75 Deserializing method should hold the following contract:
76 - None denotes a empty value for any attribute (however,
77 this is here as a safeguard, TaskWarrior currently does
78 not export empty-valued attributes) if the attribute
79 is not iterable (e.g. list or set), in which case
80 a empty iterable should be used.
82 Normalizing methods should hold the following contract:
83 - They are used to validate and normalize the user input.
84 Any attribute value that comes from the user (during Task
85 initialization, assignign values to Task attributes, or
86 filtering by user-provided values of attributes) is first
87 validated and normalized using the normalize_{key} method.
88 - If validation or normalization fails, normalizer is expected
92 def __init__(self, warrior):
93 self.warrior = warrior
95 def _deserialize(self, key, value):
96 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
97 lambda x: x if x != '' else None)
98 return hydrate_func(value)
100 def _serialize(self, key, value):
101 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
102 lambda x: x if x is not None else '')
103 return dehydrate_func(value)
105 def _normalize(self, key, value):
107 Use normalize_<key> methods to normalize user input. Any user
108 input will be normalized at the moment it is used as filter,
109 or entered as a value of Task attribute.
112 # None value should not be converted by normalizer
116 normalize_func = getattr(self, 'normalize_{0}'.format(key),
119 return normalize_func(value)
121 def timestamp_serializer(self, date):
125 # Any serialized timestamp should be localized, we need to
126 # convert to UTC before converting to string (DATE_FORMAT uses UTC)
127 date = date.astimezone(pytz.utc)
129 return date.strftime(DATE_FORMAT)
131 def timestamp_deserializer(self, date_str):
135 # Return timestamp localized in the local zone
136 naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT)
137 localized_timestamp = pytz.utc.localize(naive_timestamp)
138 return localized_timestamp.astimezone(local_zone)
140 def serialize_entry(self, value):
141 return self.timestamp_serializer(value)
143 def deserialize_entry(self, value):
144 return self.timestamp_deserializer(value)
146 def normalize_entry(self, value):
147 return self.datetime_normalizer(value)
149 def serialize_modified(self, value):
150 return self.timestamp_serializer(value)
152 def deserialize_modified(self, value):
153 return self.timestamp_deserializer(value)
155 def normalize_modified(self, value):
156 return self.datetime_normalizer(value)
158 def serialize_start(self, value):
159 return self.timestamp_serializer(value)
161 def deserialize_start(self, value):
162 return self.timestamp_deserializer(value)
164 def normalize_start(self, value):
165 return self.datetime_normalizer(value)
167 def serialize_end(self, value):
168 return self.timestamp_serializer(value)
170 def deserialize_end(self, value):
171 return self.timestamp_deserializer(value)
173 def normalize_end(self, value):
174 return self.datetime_normalizer(value)
176 def serialize_due(self, value):
177 return self.timestamp_serializer(value)
179 def deserialize_due(self, value):
180 return self.timestamp_deserializer(value)
182 def normalize_due(self, value):
183 return self.datetime_normalizer(value)
185 def serialize_scheduled(self, value):
186 return self.timestamp_serializer(value)
188 def deserialize_scheduled(self, value):
189 return self.timestamp_deserializer(value)
191 def normalize_scheduled(self, value):
192 return self.datetime_normalizer(value)
194 def serialize_until(self, value):
195 return self.timestamp_serializer(value)
197 def deserialize_until(self, value):
198 return self.timestamp_deserializer(value)
200 def normalize_until(self, value):
201 return self.datetime_normalizer(value)
203 def serialize_wait(self, value):
204 return self.timestamp_serializer(value)
206 def deserialize_wait(self, value):
207 return self.timestamp_deserializer(value)
209 def normalize_wait(self, value):
210 return self.datetime_normalizer(value)
212 def serialize_annotations(self, value):
213 value = value if value is not None else []
215 # This may seem weird, but it's correct, we want to export
216 # a list of dicts as serialized value
217 serialized_annotations = [json.loads(annotation.export_data())
218 for annotation in value]
219 return serialized_annotations if serialized_annotations else ''
221 def deserialize_annotations(self, data):
222 return [TaskAnnotation(self, d) for d in data] if data else []
224 def serialize_tags(self, tags):
225 return ','.join(tags) if tags else ''
227 def deserialize_tags(self, tags):
228 if isinstance(tags, six.string_types):
229 return tags.split(',') if tags else []
232 def serialize_depends(self, value):
233 # Return the list of uuids
234 value = value if value is not None else set()
235 return ','.join(task['uuid'] for task in value)
237 def deserialize_depends(self, raw_uuids):
238 raw_uuids = raw_uuids or '' # Convert None to empty string
239 uuids = raw_uuids.split(',')
240 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
242 def datetime_normalizer(self, value):
244 Normalizes date/datetime value (considered to come from user input)
245 to localized datetime value. Following conversions happen:
247 naive date -> localized datetime with the same date, and time=midnight
248 naive datetime -> localized datetime with the same value
249 localized datetime -> localized datetime (no conversion)
252 if (isinstance(value, datetime.date)
253 and not isinstance(value, datetime.datetime)):
254 # Convert to local midnight
255 value_full = datetime.datetime.combine(value, datetime.time.min)
256 localized = local_zone.localize(value_full)
257 elif isinstance(value, datetime.datetime):
258 if value.tzinfo is None:
259 # Convert to localized datetime object
260 localized = local_zone.localize(value)
262 # If the value is already localized, there is no need to change
263 # time zone at this point. Also None is a valid value too.
265 elif isinstance(value, six.string_types):
266 # For strings, use 'task calc' to evaluate the string to datetime
268 result = self.warrior.execute_command(['calc'] + args)
269 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
270 localized = local_zone.localize(naive)
272 raise ValueError("Provided value could not be converted to "
273 "datetime, its type is not supported: {}"
274 .format(type(value)))
278 def normalize_uuid(self, value):
280 if not isinstance(value, six.string_types) or value == '':
281 raise ValueError("UUID must be a valid non-empty string, "
282 "not: {}".format(value))
287 class TaskResource(SerializingObject):
288 read_only_fields = []
290 def _load_data(self, data):
291 self._data = dict((key, self._deserialize(key, value))
292 for key, value in data.items())
293 # We need to use a copy for original data, so that changes
294 # are not propagated.
295 self._original_data = copy.deepcopy(self._data)
297 def _update_data(self, data, update_original=False):
299 Low level update of the internal _data dict. Data which are coming as
300 updates should already be serialized. If update_original is True, the
301 original_data dict is updated as well.
303 self._data.update(dict((key, self._deserialize(key, value))
304 for key, value in data.items()))
307 self._original_data = copy.deepcopy(self._data)
310 def __getitem__(self, key):
311 # This is a workaround to make TaskResource non-iterable
312 # over simple index-based iteration
319 if key not in self._data:
320 self._data[key] = self._deserialize(key, None)
322 return self._data.get(key)
324 def __setitem__(self, key, value):
325 if key in self.read_only_fields:
326 raise RuntimeError('Field \'%s\' is read-only' % key)
328 # Normalize the user input before saving it
329 value = self._normalize(key, value)
330 self._data[key] = value
333 s = six.text_type(self.__unicode__())
335 s = s.encode('utf-8')
341 def export_data(self):
343 Exports current data contained in the Task as JSON
346 # We need to remove spaces for TW-1504, use custom separators
347 data_tuples = ((key, self._serialize(key, value))
348 for key, value in six.iteritems(self._data))
350 # Empty string denotes empty serialized value, we do not want
351 # to pass that to TaskWarrior.
352 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
353 data = dict(data_tuples)
354 return json.dumps(data, separators=(',',':'))
357 def _modified_fields(self):
358 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
359 for key in writable_fields:
360 new_value = self._data.get(key)
361 old_value = self._original_data.get(key)
363 # Make sure not to mark data removal as modified field if the
364 # field originally had some empty value
365 if key in self._data and not new_value and not old_value:
368 if new_value != old_value:
373 return bool(list(self._modified_fields))
376 class TaskAnnotation(TaskResource):
377 read_only_fields = ['entry', 'description']
379 def __init__(self, task, data={}):
381 self._load_data(data)
382 super(TaskAnnotation, self).__init__(task.warrior)
385 self.task.remove_annotation(self)
387 def __unicode__(self):
388 return self['description']
390 def __eq__(self, other):
391 # consider 2 annotations equal if they belong to the same task, and
392 # their data dics are the same
393 return self.task == other.task and self._data == other._data
395 __repr__ = __unicode__
398 class Task(TaskResource):
399 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
401 class DoesNotExist(Exception):
404 class CompletedTask(Exception):
406 Raised when the operation cannot be performed on the completed task.
410 class DeletedTask(Exception):
412 Raised when the operation cannot be performed on the deleted task.
416 class NotSaved(Exception):
418 Raised when the operation cannot be performed on the task, because
419 it has not been saved to TaskWarrior yet.
424 def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
426 Creates a Task object, directly from the stdin, by reading one line.
427 If modify=True, two lines are used, first line interpreted as the
428 original state of the Task object, and second line as its new,
429 modified value. This is consistent with the TaskWarrior's hook
432 Object created by this method should not be saved, deleted
433 or refreshed, as t could create a infinite loop. For this
434 reason, TaskWarrior instance is set to None.
436 Input_file argument can be used to specify the input file,
437 but defaults to sys.stdin.
440 # Detect the hook type if not given directly
441 name = os.path.basename(sys.argv[0])
442 modify = name.startswith('on-modify') if modify is None else modify
444 # Create the TaskWarrior instance if none passed
446 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
447 warrior = TaskWarrior(data_location=hook_parent_dir)
449 # TaskWarrior instance is set to None
452 # Load the data from the input
453 task._load_data(json.loads(input_file.readline().strip()))
455 # If this is a on-modify event, we are provided with additional
456 # line of input, which provides updated data
458 task._update_data(json.loads(input_file.readline().strip()))
462 def __init__(self, warrior, **kwargs):
463 super(Task, self).__init__(warrior)
465 # Check that user is not able to set read-only value in __init__
466 for key in kwargs.keys():
467 if key in self.read_only_fields:
468 raise RuntimeError('Field \'%s\' is read-only' % key)
470 # We serialize the data in kwargs so that users of the library
471 # do not have to pass different data formats via __setitem__ and
472 # __init__ methods, that would be confusing
474 # Rather unfortunate syntax due to python2.6 comaptiblity
475 self._data = dict((key, self._normalize(key, value))
476 for (key, value) in six.iteritems(kwargs))
477 self._original_data = copy.deepcopy(self._data)
479 # Provide read only access to the original data
480 self.original = ReadOnlyDictView(self._original_data)
482 def __unicode__(self):
483 return self['description']
485 def __eq__(self, other):
486 if self['uuid'] and other['uuid']:
487 # For saved Tasks, just define equality by equality of uuids
488 return self['uuid'] == other['uuid']
490 # If the tasks are not saved, compare the actual instances
491 return id(self) == id(other)
496 # For saved Tasks, just define equality by equality of uuids
497 return self['uuid'].__hash__()
499 # If the tasks are not saved, return hash of instance id
500 return id(self).__hash__()
504 return self['status'] == six.text_type('completed')
508 return self['status'] == six.text_type('deleted')
512 return self['status'] == six.text_type('waiting')
516 return self['status'] == six.text_type('pending')
520 return self['uuid'] is not None or self['id'] is not None
522 def serialize_depends(self, cur_dependencies):
523 # Check that all the tasks are saved
524 for task in (cur_dependencies or set()):
526 raise Task.NotSaved('Task \'%s\' needs to be saved before '
527 'it can be set as dependency.' % task)
529 return super(Task, self).serialize_depends(cur_dependencies)
531 def format_depends(self):
532 # We need to generate added and removed dependencies list,
533 # since Taskwarrior does not accept redefining dependencies.
535 # This cannot be part of serialize_depends, since we need
536 # to keep a list of all depedencies in the _data dictionary,
537 # not just currently added/removed ones
539 old_dependencies = self._original_data.get('depends', set())
541 added = self['depends'] - old_dependencies
542 removed = old_dependencies - self['depends']
544 # Removed dependencies need to be prefixed with '-'
545 return 'depends:' + ','.join(
546 [t['uuid'] for t in added] +
547 ['-' + t['uuid'] for t in removed]
550 def format_description(self):
551 # Task version older than 2.4.0 ignores first word of the
552 # task description if description: prefix is used
553 if self.warrior.version < VERSION_2_4_0:
554 return self._data['description']
556 return "description:'{0}'".format(self._data['description'] or '')
560 raise Task.NotSaved("Task needs to be saved before it can be deleted")
562 # Refresh the status, and raise exception if the task is deleted
563 self.refresh(only_fields=['status'])
566 raise Task.DeletedTask("Task was already deleted")
568 self.warrior.execute_command([self['uuid'], 'delete'])
570 # Refresh the status again, so that we have updated info stored
571 self.refresh(only_fields=['status', 'start', 'end'])
575 raise Task.NotSaved("Task needs to be saved before it can be started")
577 # Refresh, and raise exception if task is already completed/deleted
578 self.refresh(only_fields=['status'])
581 raise Task.CompletedTask("Cannot start a completed task")
583 raise Task.DeletedTask("Deleted task cannot be started")
585 self.warrior.execute_command([self['uuid'], 'start'])
587 # Refresh the status again, so that we have updated info stored
588 self.refresh(only_fields=['status', 'start'])
592 raise Task.NotSaved("Task needs to be saved before it can be completed")
594 # Refresh, and raise exception if task is already completed/deleted
595 self.refresh(only_fields=['status'])
598 raise Task.CompletedTask("Cannot complete a completed task")
600 raise Task.DeletedTask("Deleted task cannot be completed")
602 self.warrior.execute_command([self['uuid'], 'done'])
604 # Refresh the status again, so that we have updated info stored
605 self.refresh(only_fields=['status', 'start', 'end'])
608 if self.saved and not self.modified:
611 args = [self['uuid'], 'modify'] if self.saved else ['add']
612 args.extend(self._get_modified_fields_as_args())
613 output = self.warrior.execute_command(args)
615 # Parse out the new ID, if the task is being added for the first time
617 id_lines = [l for l in output if l.startswith('Created task ')]
619 # Complain loudly if it seems that more tasks were created
621 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
622 raise TaskWarriorException("Unexpected output when creating "
623 "task: %s" % '\n'.join(id_lines))
625 # Circumvent the ID storage, since ID is considered read-only
626 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
628 # Refreshing is very important here, as not only modification time
629 # is updated, but arbitrary attribute may have changed due hooks
630 # altering the data before saving
633 def add_annotation(self, annotation):
635 raise Task.NotSaved("Task needs to be saved to add annotation")
637 args = [self['uuid'], 'annotate', annotation]
638 self.warrior.execute_command(args)
639 self.refresh(only_fields=['annotations'])
641 def remove_annotation(self, annotation):
643 raise Task.NotSaved("Task needs to be saved to remove annotation")
645 if isinstance(annotation, TaskAnnotation):
646 annotation = annotation['description']
647 args = [self['uuid'], 'denotate', annotation]
648 self.warrior.execute_command(args)
649 self.refresh(only_fields=['annotations'])
651 def _get_modified_fields_as_args(self):
654 def add_field(field):
655 # Add the output of format_field method to args list (defaults to
657 serialized_value = self._serialize(field, self._data[field])
659 # Empty values should not be enclosed in quotation marks, see
661 if serialized_value is '':
662 escaped_serialized_value = ''
664 escaped_serialized_value = "'{0}'".format(serialized_value)
666 format_default = lambda: "{0}:{1}".format(field,
667 escaped_serialized_value)
669 format_func = getattr(self, 'format_{0}'.format(field),
672 args.append(format_func())
674 # If we're modifying saved task, simply pass on all modified fields
676 for field in self._modified_fields:
678 # For new tasks, pass all fields that make sense
680 for field in self._data.keys():
681 if field in self.read_only_fields:
687 def refresh(self, only_fields=[]):
688 # Raise error when trying to refresh a task that has not been saved
690 raise Task.NotSaved("Task needs to be saved to be refreshed")
692 # We need to use ID as backup for uuid here for the refreshes
693 # of newly saved tasks. Any other place in the code is fine
694 # with using UUID only.
695 args = [self['uuid'] or self['id'], 'export']
696 new_data = json.loads(self.warrior.execute_command(args)[0])
699 [(k, new_data.get(k)) for k in only_fields])
700 self._update_data(to_update, update_original=True)
702 self._load_data(new_data)
704 class TaskFilter(SerializingObject):
706 A set of parameters to filter the task list with.
709 def __init__(self, warrior, filter_params=[]):
710 self.filter_params = filter_params
711 super(TaskFilter, self).__init__(warrior)
713 def add_filter(self, filter_str):
714 self.filter_params.append(filter_str)
716 def add_filter_param(self, key, value):
717 key = key.replace('__', '.')
719 # Replace the value with empty string, since that is the
720 # convention in TW for empty values
721 attribute_key = key.split('.')[0]
723 # Since this is user input, we need to normalize before we serialize
724 value = self._normalize(attribute_key, value)
725 value = self._serialize(attribute_key, value)
727 # If we are filtering by uuid:, do not use uuid keyword
730 self.filter_params.insert(0, value)
732 # Surround value with aphostrophes unless it's a empty string
733 value = "'%s'" % value if value else ''
735 # We enforce equality match by using 'is' (or 'none') modifier
736 # Without using this syntax, filter fails due to TW-1479
737 modifier = '.is' if value else '.none'
738 key = key + modifier if '.' not in key else key
740 self.filter_params.append("{0}:{1}".format(key, value))
742 def get_filter_params(self):
743 return [f for f in self.filter_params if f]
746 c = self.__class__(self.warrior)
747 c.filter_params = list(self.filter_params)
751 class TaskQuerySet(object):
753 Represents a lazy lookup for a task objects.
756 def __init__(self, warrior=None, filter_obj=None):
757 self.warrior = warrior
758 self._result_cache = None
759 self.filter_obj = filter_obj or TaskFilter(warrior)
761 def __deepcopy__(self, memo):
763 Deep copy of a QuerySet doesn't populate the cache
765 obj = self.__class__()
766 for k, v in self.__dict__.items():
767 if k in ('_iter', '_result_cache'):
768 obj.__dict__[k] = None
770 obj.__dict__[k] = copy.deepcopy(v, memo)
774 data = list(self[:REPR_OUTPUT_SIZE + 1])
775 if len(data) > REPR_OUTPUT_SIZE:
776 data[-1] = "...(remaining elements truncated)..."
780 if self._result_cache is None:
781 self._result_cache = list(self)
782 return len(self._result_cache)
785 if self._result_cache is None:
786 self._result_cache = self._execute()
787 return iter(self._result_cache)
789 def __getitem__(self, k):
790 if self._result_cache is None:
791 self._result_cache = list(self)
792 return self._result_cache.__getitem__(k)
795 if self._result_cache is not None:
796 return bool(self._result_cache)
799 except StopIteration:
803 def __nonzero__(self):
804 return type(self).__bool__(self)
806 def _clone(self, klass=None, **kwargs):
808 klass = self.__class__
809 filter_obj = self.filter_obj.clone()
810 c = klass(warrior=self.warrior, filter_obj=filter_obj)
811 c.__dict__.update(kwargs)
816 Fetch the tasks which match the current filters.
818 return self.warrior.filter_tasks(self.filter_obj)
822 Returns a new TaskQuerySet that is a copy of the current one.
827 return self.filter(status=PENDING)
830 return self.filter(status=COMPLETED)
832 def filter(self, *args, **kwargs):
834 Returns a new TaskQuerySet with the given filters added.
836 clone = self._clone()
838 clone.filter_obj.add_filter(f)
839 for key, value in kwargs.items():
840 clone.filter_obj.add_filter_param(key, value)
843 def get(self, **kwargs):
845 Performs the query and returns a single object matching the given
848 clone = self.filter(**kwargs)
851 return clone._result_cache[0]
853 raise Task.DoesNotExist(
854 'Task matching query does not exist. '
855 'Lookup parameters were {0}'.format(kwargs))
857 'get() returned more than one Task -- it returned {0}! '
858 'Lookup parameters were {1}'.format(num, kwargs))
861 class TaskWarrior(object):
862 def __init__(self, data_location='~/.task', create=True, taskrc_location='~/.taskrc'):
863 data_location = os.path.expanduser(data_location)
864 self.taskrc_location = os.path.expanduser(taskrc_location)
866 # If taskrc does not exist, pass / to use defaults and avoid creating
867 # dummy .taskrc file by TaskWarrior
868 if not os.path.exists(self.taskrc_location):
869 self.taskrc_location = '/'
871 if create and not os.path.exists(data_location):
872 os.makedirs(data_location)
875 'data.location': data_location,
876 'confirmation': 'no',
877 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
878 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
880 self.tasks = TaskQuerySet(self)
881 self.version = self._get_version()
883 def _get_command_args(self, args, config_override={}):
884 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
885 config = self.config.copy()
886 config.update(config_override)
887 for item in config.items():
888 command_args.append('rc.{0}={1}'.format(*item))
889 command_args.extend(map(str, args))
892 def _get_version(self):
893 p = subprocess.Popen(
894 ['task', '--version'],
895 stdout=subprocess.PIPE,
896 stderr=subprocess.PIPE)
897 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
898 return stdout.strip('\n')
900 def execute_command(self, args, config_override={}, allow_failure=True):
901 command_args = self._get_command_args(
902 args, config_override=config_override)
903 logger.debug(' '.join(command_args))
904 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
905 stderr=subprocess.PIPE)
906 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
907 if p.returncode and allow_failure:
909 error_msg = stderr.strip()
911 error_msg = stdout.strip()
912 raise TaskWarriorException(error_msg)
913 return stdout.strip().split('\n')
915 def enforce_recurrence(self):
916 # Run arbitrary report command which will trigger generation
917 # of recurrent tasks.
919 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
920 if self.version < VERSION_2_4_2:
921 self.execute_command(['next'], allow_failure=False)
923 def filter_tasks(self, filter_obj):
924 self.enforce_recurrence()
925 args = ['export', '--'] + filter_obj.get_filter_params()
927 for line in self.execute_command(args):
929 data = line.strip(',')
931 filtered_task = Task(self)
932 filtered_task._load_data(json.loads(data))
933 tasks.append(filtered_task)
935 raise TaskWarriorException('Invalid JSON: %s' % data)
938 def merge_with(self, path, push=False):
939 path = path.rstrip('/') + '/'
940 self.execute_command(['merge', path], config_override={
941 'merge.autopush': 'yes' if push else 'no',
945 self.execute_command(['undo'])