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 and self.warrior.version > VERSION_2_4_0):
267 # For strings, use 'task calc' to evaluate the string to datetime
268 # available since TW 2.4.0
270 result = self.warrior.execute_command(['calc'] + args)
271 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
272 localized = local_zone.localize(naive)
274 raise ValueError("Provided value could not be converted to "
275 "datetime, its type is not supported: {}"
276 .format(type(value)))
280 def normalize_uuid(self, value):
282 if not isinstance(value, six.string_types) or value == '':
283 raise ValueError("UUID must be a valid non-empty string, "
284 "not: {}".format(value))
289 class TaskResource(SerializingObject):
290 read_only_fields = []
292 def _load_data(self, data):
293 self._data = dict((key, self._deserialize(key, value))
294 for key, value in data.items())
295 # We need to use a copy for original data, so that changes
296 # are not propagated.
297 self._original_data = copy.deepcopy(self._data)
299 def _update_data(self, data, update_original=False):
301 Low level update of the internal _data dict. Data which are coming as
302 updates should already be serialized. If update_original is True, the
303 original_data dict is updated as well.
305 self._data.update(dict((key, self._deserialize(key, value))
306 for key, value in data.items()))
309 self._original_data = copy.deepcopy(self._data)
312 def __getitem__(self, key):
313 # This is a workaround to make TaskResource non-iterable
314 # over simple index-based iteration
321 if key not in self._data:
322 self._data[key] = self._deserialize(key, None)
324 return self._data.get(key)
326 def __setitem__(self, key, value):
327 if key in self.read_only_fields:
328 raise RuntimeError('Field \'%s\' is read-only' % key)
330 # Normalize the user input before saving it
331 value = self._normalize(key, value)
332 self._data[key] = value
335 s = six.text_type(self.__unicode__())
337 s = s.encode('utf-8')
343 def export_data(self):
345 Exports current data contained in the Task as JSON
348 # We need to remove spaces for TW-1504, use custom separators
349 data_tuples = ((key, self._serialize(key, value))
350 for key, value in six.iteritems(self._data))
352 # Empty string denotes empty serialized value, we do not want
353 # to pass that to TaskWarrior.
354 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
355 data = dict(data_tuples)
356 return json.dumps(data, separators=(',',':'))
359 def _modified_fields(self):
360 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
361 for key in writable_fields:
362 new_value = self._data.get(key)
363 old_value = self._original_data.get(key)
365 # Make sure not to mark data removal as modified field if the
366 # field originally had some empty value
367 if key in self._data and not new_value and not old_value:
370 if new_value != old_value:
375 return bool(list(self._modified_fields))
378 class TaskAnnotation(TaskResource):
379 read_only_fields = ['entry', 'description']
381 def __init__(self, task, data={}):
383 self._load_data(data)
384 super(TaskAnnotation, self).__init__(task.warrior)
387 self.task.remove_annotation(self)
389 def __unicode__(self):
390 return self['description']
392 def __eq__(self, other):
393 # consider 2 annotations equal if they belong to the same task, and
394 # their data dics are the same
395 return self.task == other.task and self._data == other._data
397 __repr__ = __unicode__
400 class Task(TaskResource):
401 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
403 class DoesNotExist(Exception):
406 class CompletedTask(Exception):
408 Raised when the operation cannot be performed on the completed task.
412 class DeletedTask(Exception):
414 Raised when the operation cannot be performed on the deleted task.
418 class NotSaved(Exception):
420 Raised when the operation cannot be performed on the task, because
421 it has not been saved to TaskWarrior yet.
426 def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
428 Creates a Task object, directly from the stdin, by reading one line.
429 If modify=True, two lines are used, first line interpreted as the
430 original state of the Task object, and second line as its new,
431 modified value. This is consistent with the TaskWarrior's hook
434 Object created by this method should not be saved, deleted
435 or refreshed, as t could create a infinite loop. For this
436 reason, TaskWarrior instance is set to None.
438 Input_file argument can be used to specify the input file,
439 but defaults to sys.stdin.
442 # Detect the hook type if not given directly
443 name = os.path.basename(sys.argv[0])
444 modify = name.startswith('on-modify') if modify is None else modify
446 # Create the TaskWarrior instance if none passed
448 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
449 warrior = TaskWarrior(data_location=hook_parent_dir)
451 # TaskWarrior instance is set to None
454 # Load the data from the input
455 task._load_data(json.loads(input_file.readline().strip()))
457 # If this is a on-modify event, we are provided with additional
458 # line of input, which provides updated data
460 task._update_data(json.loads(input_file.readline().strip()))
464 def __init__(self, warrior, **kwargs):
465 super(Task, self).__init__(warrior)
467 # Check that user is not able to set read-only value in __init__
468 for key in kwargs.keys():
469 if key in self.read_only_fields:
470 raise RuntimeError('Field \'%s\' is read-only' % key)
472 # We serialize the data in kwargs so that users of the library
473 # do not have to pass different data formats via __setitem__ and
474 # __init__ methods, that would be confusing
476 # Rather unfortunate syntax due to python2.6 comaptiblity
477 self._data = dict((key, self._normalize(key, value))
478 for (key, value) in six.iteritems(kwargs))
479 self._original_data = copy.deepcopy(self._data)
481 # Provide read only access to the original data
482 self.original = ReadOnlyDictView(self._original_data)
484 def __unicode__(self):
485 return self['description']
487 def __eq__(self, other):
488 if self['uuid'] and other['uuid']:
489 # For saved Tasks, just define equality by equality of uuids
490 return self['uuid'] == other['uuid']
492 # If the tasks are not saved, compare the actual instances
493 return id(self) == id(other)
498 # For saved Tasks, just define equality by equality of uuids
499 return self['uuid'].__hash__()
501 # If the tasks are not saved, return hash of instance id
502 return id(self).__hash__()
506 return self['status'] == six.text_type('completed')
510 return self['status'] == six.text_type('deleted')
514 return self['status'] == six.text_type('waiting')
518 return self['status'] == six.text_type('pending')
522 return self['uuid'] is not None or self['id'] is not None
524 def serialize_depends(self, cur_dependencies):
525 # Check that all the tasks are saved
526 for task in (cur_dependencies or set()):
528 raise Task.NotSaved('Task \'%s\' needs to be saved before '
529 'it can be set as dependency.' % task)
531 return super(Task, self).serialize_depends(cur_dependencies)
533 def format_depends(self):
534 # We need to generate added and removed dependencies list,
535 # since Taskwarrior does not accept redefining dependencies.
537 # This cannot be part of serialize_depends, since we need
538 # to keep a list of all depedencies in the _data dictionary,
539 # not just currently added/removed ones
541 old_dependencies = self._original_data.get('depends', set())
543 added = self['depends'] - old_dependencies
544 removed = old_dependencies - self['depends']
546 # Removed dependencies need to be prefixed with '-'
547 return 'depends:' + ','.join(
548 [t['uuid'] for t in added] +
549 ['-' + t['uuid'] for t in removed]
552 def format_description(self):
553 # Task version older than 2.4.0 ignores first word of the
554 # task description if description: prefix is used
555 if self.warrior.version < VERSION_2_4_0:
556 return self._data['description']
558 return "description:'{0}'".format(self._data['description'] or '')
562 raise Task.NotSaved("Task needs to be saved before it can be deleted")
564 # Refresh the status, and raise exception if the task is deleted
565 self.refresh(only_fields=['status'])
568 raise Task.DeletedTask("Task was already deleted")
570 self.warrior.execute_command([self['uuid'], 'delete'])
572 # Refresh the status again, so that we have updated info stored
573 self.refresh(only_fields=['status', 'start', 'end'])
577 raise Task.NotSaved("Task needs to be saved before it can be started")
579 # Refresh, and raise exception if task is already completed/deleted
580 self.refresh(only_fields=['status'])
583 raise Task.CompletedTask("Cannot start a completed task")
585 raise Task.DeletedTask("Deleted task cannot be started")
587 self.warrior.execute_command([self['uuid'], 'start'])
589 # Refresh the status again, so that we have updated info stored
590 self.refresh(only_fields=['status', 'start'])
594 raise Task.NotSaved("Task needs to be saved before it can be completed")
596 # Refresh, and raise exception if task is already completed/deleted
597 self.refresh(only_fields=['status'])
600 raise Task.CompletedTask("Cannot complete a completed task")
602 raise Task.DeletedTask("Deleted task cannot be completed")
604 self.warrior.execute_command([self['uuid'], 'done'])
606 # Refresh the status again, so that we have updated info stored
607 self.refresh(only_fields=['status', 'start', 'end'])
610 if self.saved and not self.modified:
613 args = [self['uuid'], 'modify'] if self.saved else ['add']
614 args.extend(self._get_modified_fields_as_args())
615 output = self.warrior.execute_command(args)
617 # Parse out the new ID, if the task is being added for the first time
619 id_lines = [l for l in output if l.startswith('Created task ')]
621 # Complain loudly if it seems that more tasks were created
623 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
624 raise TaskWarriorException("Unexpected output when creating "
625 "task: %s" % '\n'.join(id_lines))
627 # Circumvent the ID storage, since ID is considered read-only
628 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
630 # Refreshing is very important here, as not only modification time
631 # is updated, but arbitrary attribute may have changed due hooks
632 # altering the data before saving
635 def add_annotation(self, annotation):
637 raise Task.NotSaved("Task needs to be saved to add annotation")
639 args = [self['uuid'], 'annotate', annotation]
640 self.warrior.execute_command(args)
641 self.refresh(only_fields=['annotations'])
643 def remove_annotation(self, annotation):
645 raise Task.NotSaved("Task needs to be saved to remove annotation")
647 if isinstance(annotation, TaskAnnotation):
648 annotation = annotation['description']
649 args = [self['uuid'], 'denotate', annotation]
650 self.warrior.execute_command(args)
651 self.refresh(only_fields=['annotations'])
653 def _get_modified_fields_as_args(self):
656 def add_field(field):
657 # Add the output of format_field method to args list (defaults to
659 serialized_value = self._serialize(field, self._data[field])
661 # Empty values should not be enclosed in quotation marks, see
663 if serialized_value is '':
664 escaped_serialized_value = ''
666 escaped_serialized_value = "'{0}'".format(serialized_value)
668 format_default = lambda: "{0}:{1}".format(field,
669 escaped_serialized_value)
671 format_func = getattr(self, 'format_{0}'.format(field),
674 args.append(format_func())
676 # If we're modifying saved task, simply pass on all modified fields
678 for field in self._modified_fields:
680 # For new tasks, pass all fields that make sense
682 for field in self._data.keys():
683 if field in self.read_only_fields:
689 def refresh(self, only_fields=[]):
690 # Raise error when trying to refresh a task that has not been saved
692 raise Task.NotSaved("Task needs to be saved to be refreshed")
694 # We need to use ID as backup for uuid here for the refreshes
695 # of newly saved tasks. Any other place in the code is fine
696 # with using UUID only.
697 args = [self['uuid'] or self['id'], 'export']
698 new_data = json.loads(self.warrior.execute_command(args)[0])
701 [(k, new_data.get(k)) for k in only_fields])
702 self._update_data(to_update, update_original=True)
704 self._load_data(new_data)
706 class TaskFilter(SerializingObject):
708 A set of parameters to filter the task list with.
711 def __init__(self, warrior, filter_params=[]):
712 self.filter_params = filter_params
713 super(TaskFilter, self).__init__(warrior)
715 def add_filter(self, filter_str):
716 self.filter_params.append(filter_str)
718 def add_filter_param(self, key, value):
719 key = key.replace('__', '.')
721 # Replace the value with empty string, since that is the
722 # convention in TW for empty values
723 attribute_key = key.split('.')[0]
725 # Since this is user input, we need to normalize before we serialize
726 value = self._normalize(attribute_key, value)
727 value = self._serialize(attribute_key, value)
729 # If we are filtering by uuid:, do not use uuid keyword
732 self.filter_params.insert(0, value)
734 # Surround value with aphostrophes unless it's a empty string
735 value = "'%s'" % value if value else ''
737 # We enforce equality match by using 'is' (or 'none') modifier
738 # Without using this syntax, filter fails due to TW-1479
739 modifier = '.is' if value else '.none'
740 key = key + modifier if '.' not in key else key
742 self.filter_params.append("{0}:{1}".format(key, value))
744 def get_filter_params(self):
745 return [f for f in self.filter_params if f]
748 c = self.__class__(self.warrior)
749 c.filter_params = list(self.filter_params)
753 class TaskQuerySet(object):
755 Represents a lazy lookup for a task objects.
758 def __init__(self, warrior=None, filter_obj=None):
759 self.warrior = warrior
760 self._result_cache = None
761 self.filter_obj = filter_obj or TaskFilter(warrior)
763 def __deepcopy__(self, memo):
765 Deep copy of a QuerySet doesn't populate the cache
767 obj = self.__class__()
768 for k, v in self.__dict__.items():
769 if k in ('_iter', '_result_cache'):
770 obj.__dict__[k] = None
772 obj.__dict__[k] = copy.deepcopy(v, memo)
776 data = list(self[:REPR_OUTPUT_SIZE + 1])
777 if len(data) > REPR_OUTPUT_SIZE:
778 data[-1] = "...(remaining elements truncated)..."
782 if self._result_cache is None:
783 self._result_cache = list(self)
784 return len(self._result_cache)
787 if self._result_cache is None:
788 self._result_cache = self._execute()
789 return iter(self._result_cache)
791 def __getitem__(self, k):
792 if self._result_cache is None:
793 self._result_cache = list(self)
794 return self._result_cache.__getitem__(k)
797 if self._result_cache is not None:
798 return bool(self._result_cache)
801 except StopIteration:
805 def __nonzero__(self):
806 return type(self).__bool__(self)
808 def _clone(self, klass=None, **kwargs):
810 klass = self.__class__
811 filter_obj = self.filter_obj.clone()
812 c = klass(warrior=self.warrior, filter_obj=filter_obj)
813 c.__dict__.update(kwargs)
818 Fetch the tasks which match the current filters.
820 return self.warrior.filter_tasks(self.filter_obj)
824 Returns a new TaskQuerySet that is a copy of the current one.
829 return self.filter(status=PENDING)
832 return self.filter(status=COMPLETED)
834 def filter(self, *args, **kwargs):
836 Returns a new TaskQuerySet with the given filters added.
838 clone = self._clone()
840 clone.filter_obj.add_filter(f)
841 for key, value in kwargs.items():
842 clone.filter_obj.add_filter_param(key, value)
845 def get(self, **kwargs):
847 Performs the query and returns a single object matching the given
850 clone = self.filter(**kwargs)
853 return clone._result_cache[0]
855 raise Task.DoesNotExist(
856 'Task matching query does not exist. '
857 'Lookup parameters were {0}'.format(kwargs))
859 'get() returned more than one Task -- it returned {0}! '
860 'Lookup parameters were {1}'.format(num, kwargs))
863 class TaskWarrior(object):
864 def __init__(self, data_location='~/.task', create=True, taskrc_location='~/.taskrc'):
865 data_location = os.path.expanduser(data_location)
866 self.taskrc_location = os.path.expanduser(taskrc_location)
868 # If taskrc does not exist, pass / to use defaults and avoid creating
869 # dummy .taskrc file by TaskWarrior
870 if not os.path.exists(self.taskrc_location):
871 self.taskrc_location = '/'
873 if create and not os.path.exists(data_location):
874 os.makedirs(data_location)
877 'data.location': data_location,
878 'confirmation': 'no',
879 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
880 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
882 self.tasks = TaskQuerySet(self)
883 self.version = self._get_version()
885 def _get_command_args(self, args, config_override={}):
886 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
887 config = self.config.copy()
888 config.update(config_override)
889 for item in config.items():
890 command_args.append('rc.{0}={1}'.format(*item))
891 command_args.extend(map(str, args))
894 def _get_version(self):
895 p = subprocess.Popen(
896 ['task', '--version'],
897 stdout=subprocess.PIPE,
898 stderr=subprocess.PIPE)
899 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
900 return stdout.strip('\n')
902 def execute_command(self, args, config_override={}, allow_failure=True):
903 command_args = self._get_command_args(
904 args, config_override=config_override)
905 logger.debug(' '.join(command_args))
906 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
907 stderr=subprocess.PIPE)
908 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
909 if p.returncode and allow_failure:
911 error_msg = stderr.strip()
913 error_msg = stdout.strip()
914 raise TaskWarriorException(error_msg)
915 return stdout.strip().split('\n')
917 def enforce_recurrence(self):
918 # Run arbitrary report command which will trigger generation
919 # of recurrent tasks.
921 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
922 if self.version < VERSION_2_4_2:
923 self.execute_command(['next'], allow_failure=False)
925 def filter_tasks(self, filter_obj):
926 self.enforce_recurrence()
927 args = ['export', '--'] + filter_obj.get_filter_params()
929 for line in self.execute_command(args):
931 data = line.strip(',')
933 filtered_task = Task(self)
934 filtered_task._load_data(json.loads(data))
935 tasks.append(filtered_task)
937 raise TaskWarriorException('Invalid JSON: %s' % data)
940 def merge_with(self, path, push=False):
941 path = path.rstrip('/') + '/'
942 self.execute_command(['merge', path], config_override={
943 'merge.autopush': 'yes' if push else 'no',
947 self.execute_command(['undo'])