]> git.madduck.net Git - etc/taskwarrior.git/blob - tasklib/task.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

Task: Use localized timestamps in UTC
[etc/taskwarrior.git] / tasklib / task.py
1 from __future__ import print_function
2 import copy
3 import datetime
4 import json
5 import logging
6 import os
7 import pytz
8 import six
9 import sys
10 import subprocess
11 import tzlocal
12
13 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
14 REPR_OUTPUT_SIZE = 10
15 PENDING = 'pending'
16 COMPLETED = 'completed'
17
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')
22
23 logger = logging.getLogger(__name__)
24 local_zone = tzlocal.get_localzone()
25
26
27 class TaskWarriorException(Exception):
28     pass
29
30
31 class SerializingObject(object):
32     """
33     Common ancestor for TaskResource & TaskFilter, since they both
34     need to serialize arguments.
35
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
40
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.
47     """
48
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)
53
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)
58
59     def _normalize(self, key, value):
60         """
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.
64         """
65
66         normalize_func = getattr(self, 'normalize_{0}'.format(key),
67                                  lambda x: x)
68
69         return normalize_func(value)
70
71     def timestamp_serializer(self, date):
72         if not date:
73             return ''
74
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)
78
79         return date.strftime(DATE_FORMAT)
80
81     def timestamp_deserializer(self, date_str):
82         if not date_str:
83             return None
84
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)
89
90     def serialize_entry(self, value):
91         return self.timestamp_serializer(value)
92
93     def deserialize_entry(self, value):
94         return self.timestamp_deserializer(value)
95
96     def serialize_modified(self, value):
97         return self.timestamp_serializer(value)
98
99     def deserialize_modified(self, value):
100         return self.timestamp_deserializer(value)
101
102     def serialize_due(self, value):
103         return self.timestamp_serializer(value)
104
105     def deserialize_due(self, value):
106         return self.timestamp_deserializer(value)
107
108     def serialize_scheduled(self, value):
109         return self.timestamp_serializer(value)
110
111     def deserialize_scheduled(self, value):
112         return self.timestamp_deserializer(value)
113
114     def serialize_until(self, value):
115         return self.timestamp_serializer(value)
116
117     def deserialize_until(self, value):
118         return self.timestamp_deserializer(value)
119
120     def serialize_wait(self, value):
121         return self.timestamp_serializer(value)
122
123     def deserialize_wait(self, value):
124         return self.timestamp_deserializer(value)
125
126     def serialize_annotations(self, value):
127         value = value if value is not None else []
128
129         # This may seem weird, but it's correct, we want to export
130         # a list of dicts as serialized value
131         serialized_annotations = [json.loads(annotation.export_data())
132                                   for annotation in value]
133         return serialized_annotations if serialized_annotations else ''
134
135     def deserialize_annotations(self, data):
136         return [TaskAnnotation(self, d) for d in data] if data else []
137
138     def serialize_tags(self, tags):
139         return ','.join(tags) if tags else ''
140
141     def deserialize_tags(self, tags):
142         if isinstance(tags, six.string_types):
143             return tags.split(',') if tags else []
144         return tags or []
145
146     def serialize_depends(self, value):
147         # Return the list of uuids
148         value = value if value is not None else set()
149         return ','.join(task['uuid'] for task in value)
150
151     def deserialize_depends(self, raw_uuids):
152         raw_uuids = raw_uuids or ''  # Convert None to empty string
153         uuids = raw_uuids.split(',')
154         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
155
156     def normalize_datetime(self, value):
157         """
158         Normalizes date/datetime value (considered to come from user input)
159         to localized datetime value. Following conversions happen:
160
161         naive date -> localized datetime with the same date, and time=midnight
162         naive datetime -> localized datetime with the same value
163         localized datetime -> localized datetime (no conversion)
164         """
165
166         if (isinstance(value, datetime.date)
167             and not isinstance(value, datetime.datetime)):
168             # Convert to local midnight
169             value_full = datetime.datetime.combine(value, datetime.time.min)
170             localized = local_zone.localize(value_full)
171         elif isinstance(value, datetime.datetime) and value.tzinfo is None:
172             # Convert to localized datetime object
173             localized = local_zone.localize(value)
174         else:
175             # If the value is already localized, there is no need to change
176             # time zone at this point. Also None is a valid value too.
177             localized = value
178         
179         return localized
180             
181
182
183 class TaskResource(SerializingObject):
184     read_only_fields = []
185
186     def _load_data(self, data):
187         self._data = dict((key, self._deserialize(key, value))
188                           for key, value in data.items())
189         # We need to use a copy for original data, so that changes
190         # are not propagated.
191         self._original_data = copy.deepcopy(self._data)
192
193     def _update_data(self, data, update_original=False):
194         """
195         Low level update of the internal _data dict. Data which are coming as
196         updates should already be serialized. If update_original is True, the
197         original_data dict is updated as well.
198         """
199         self._data.update(dict((key, self._deserialize(key, value))
200                                for key, value in data.items()))
201
202         if update_original:
203             self._original_data = copy.deepcopy(self._data)
204
205
206     def __getitem__(self, key):
207         # This is a workaround to make TaskResource non-iterable
208         # over simple index-based iteration
209         try:
210             int(key)
211             raise StopIteration
212         except ValueError:
213             pass
214
215         if key not in self._data:
216             self._data[key] = self._deserialize(key, None)
217
218         return self._data.get(key)
219
220     def __setitem__(self, key, value):
221         if key in self.read_only_fields:
222             raise RuntimeError('Field \'%s\' is read-only' % key)
223
224         # Localize any naive date/datetime to the detected timezone
225         if (isinstance(value, datetime.datetime) or
226             isinstance(value, datetime.date)):
227             value = self.normalize_datetime(value)
228
229         self._data[key] = value
230
231     def __str__(self):
232         s = six.text_type(self.__unicode__())
233         if not six.PY3:
234             s = s.encode('utf-8')
235         return s
236
237     def __repr__(self):
238         return str(self)
239
240     def export_data(self):
241         """
242         Exports current data contained in the Task as JSON
243         """
244
245         # We need to remove spaces for TW-1504, use custom separators
246         data_tuples = ((key, self._serialize(key, value))
247                        for key, value in six.iteritems(self._data))
248
249         # Empty string denotes empty serialized value, we do not want
250         # to pass that to TaskWarrior.
251         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
252         data = dict(data_tuples)
253         return json.dumps(data, separators=(',',':'))
254
255     @property
256     def _modified_fields(self):
257         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
258         for key in writable_fields:
259             new_value = self._data.get(key)
260             old_value = self._original_data.get(key)
261
262             # Make sure not to mark data removal as modified field if the
263             # field originally had some empty value
264             if key in self._data and not new_value and not old_value:
265                 continue
266
267             if new_value != old_value:
268                 yield key
269
270     @property
271     def modified(self):
272         return bool(list(self._modified_fields))
273
274
275 class TaskAnnotation(TaskResource):
276     read_only_fields = ['entry', 'description']
277
278     def __init__(self, task, data={}):
279         self.task = task
280         self._load_data(data)
281
282     def remove(self):
283         self.task.remove_annotation(self)
284
285     def __unicode__(self):
286         return self['description']
287
288     def __eq__(self, other):
289         # consider 2 annotations equal if they belong to the same task, and
290         # their data dics are the same
291         return self.task == other.task and self._data == other._data
292
293     __repr__ = __unicode__
294
295
296 class Task(TaskResource):
297     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
298
299     class DoesNotExist(Exception):
300         pass
301
302     class CompletedTask(Exception):
303         """
304         Raised when the operation cannot be performed on the completed task.
305         """
306         pass
307
308     class DeletedTask(Exception):
309         """
310         Raised when the operation cannot be performed on the deleted task.
311         """
312         pass
313
314     class NotSaved(Exception):
315         """
316         Raised when the operation cannot be performed on the task, because
317         it has not been saved to TaskWarrior yet.
318         """
319         pass
320
321     @classmethod
322     def from_input(cls, input_file=sys.stdin, modify=None):
323         """
324         Creates a Task object, directly from the stdin, by reading one line.
325         If modify=True, two lines are used, first line interpreted as the
326         original state of the Task object, and second line as its new,
327         modified value. This is consistent with the TaskWarrior's hook
328         system.
329
330         Object created by this method should not be saved, deleted
331         or refreshed, as t could create a infinite loop. For this
332         reason, TaskWarrior instance is set to None.
333
334         Input_file argument can be used to specify the input file,
335         but defaults to sys.stdin.
336         """
337
338         # TaskWarrior instance is set to None
339         task = cls(None)
340
341         # Detect the hook type if not given directly
342         name = os.path.basename(sys.argv[0])
343         modify = name.startswith('on-modify') if modify is None else modify
344
345         # Load the data from the input
346         task._load_data(json.loads(input_file.readline().strip()))
347
348         # If this is a on-modify event, we are provided with additional
349         # line of input, which provides updated data
350         if modify:
351             task._update_data(json.loads(input_file.readline().strip()))
352
353         return task
354
355     def __init__(self, warrior, **kwargs):
356         self.warrior = warrior
357
358         # Check that user is not able to set read-only value in __init__
359         for key in kwargs.keys():
360             if key in self.read_only_fields:
361                 raise RuntimeError('Field \'%s\' is read-only' % key)
362
363         # We serialize the data in kwargs so that users of the library
364         # do not have to pass different data formats via __setitem__ and
365         # __init__ methods, that would be confusing
366
367         # Rather unfortunate syntax due to python2.6 comaptiblity
368         self._data = dict((key, self._normalize(key, value))
369                           for (key, value) in six.iteritems(kwargs))
370         self._original_data = copy.deepcopy(self._data)
371
372     def __unicode__(self):
373         return self['description']
374
375     def __eq__(self, other):
376         if self['uuid'] and other['uuid']:
377             # For saved Tasks, just define equality by equality of uuids
378             return self['uuid'] == other['uuid']
379         else:
380             # If the tasks are not saved, compare the actual instances
381             return id(self) == id(other)
382
383
384     def __hash__(self):
385         if self['uuid']:
386             # For saved Tasks, just define equality by equality of uuids
387             return self['uuid'].__hash__()
388         else:
389             # If the tasks are not saved, return hash of instance id
390             return id(self).__hash__()
391
392     @property
393     def completed(self):
394         return self['status'] == six.text_type('completed')
395
396     @property
397     def deleted(self):
398         return self['status'] == six.text_type('deleted')
399
400     @property
401     def waiting(self):
402         return self['status'] == six.text_type('waiting')
403
404     @property
405     def pending(self):
406         return self['status'] == six.text_type('pending')
407
408     @property
409     def saved(self):
410         return self['uuid'] is not None or self['id'] is not None
411
412     def serialize_depends(self, cur_dependencies):
413         # Check that all the tasks are saved
414         for task in (cur_dependencies or set()):
415             if not task.saved:
416                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
417                                     'it can be set as dependency.' % task)
418
419         return super(Task, self).serialize_depends(cur_dependencies)
420
421     def format_depends(self):
422         # We need to generate added and removed dependencies list,
423         # since Taskwarrior does not accept redefining dependencies.
424
425         # This cannot be part of serialize_depends, since we need
426         # to keep a list of all depedencies in the _data dictionary,
427         # not just currently added/removed ones
428
429         old_dependencies = self._original_data.get('depends', set())
430
431         added = self['depends'] - old_dependencies
432         removed = old_dependencies - self['depends']
433
434         # Removed dependencies need to be prefixed with '-'
435         return 'depends:' + ','.join(
436                 [t['uuid'] for t in added] +
437                 ['-' + t['uuid'] for t in removed]
438             )
439
440     def format_description(self):
441         # Task version older than 2.4.0 ignores first word of the
442         # task description if description: prefix is used
443         if self.warrior.version < VERSION_2_4_0:
444             return self._data['description']
445         else:
446             return "description:'{0}'".format(self._data['description'] or '')
447
448     def delete(self):
449         if not self.saved:
450             raise Task.NotSaved("Task needs to be saved before it can be deleted")
451
452         # Refresh the status, and raise exception if the task is deleted
453         self.refresh(only_fields=['status'])
454
455         if self.deleted:
456             raise Task.DeletedTask("Task was already deleted")
457
458         self.warrior.execute_command([self['uuid'], 'delete'])
459
460         # Refresh the status again, so that we have updated info stored
461         self.refresh(only_fields=['status'])
462
463
464     def done(self):
465         if not self.saved:
466             raise Task.NotSaved("Task needs to be saved before it can be completed")
467
468         # Refresh, and raise exception if task is already completed/deleted
469         self.refresh(only_fields=['status'])
470
471         if self.completed:
472             raise Task.CompletedTask("Cannot complete a completed task")
473         elif self.deleted:
474             raise Task.DeletedTask("Deleted task cannot be completed")
475
476         self.warrior.execute_command([self['uuid'], 'done'])
477
478         # Refresh the status again, so that we have updated info stored
479         self.refresh(only_fields=['status'])
480
481     def save(self):
482         if self.saved and not self.modified:
483             return
484
485         args = [self['uuid'], 'modify'] if self.saved else ['add']
486         args.extend(self._get_modified_fields_as_args())
487         output = self.warrior.execute_command(args)
488
489         # Parse out the new ID, if the task is being added for the first time
490         if not self.saved:
491             id_lines = [l for l in output if l.startswith('Created task ')]
492
493             # Complain loudly if it seems that more tasks were created
494             # Should not happen
495             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
496                 raise TaskWarriorException("Unexpected output when creating "
497                                            "task: %s" % '\n'.join(id_lines))
498
499             # Circumvent the ID storage, since ID is considered read-only
500             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
501
502         # Refreshing is very important here, as not only modification time
503         # is updated, but arbitrary attribute may have changed due hooks
504         # altering the data before saving
505         self.refresh()
506
507     def add_annotation(self, annotation):
508         if not self.saved:
509             raise Task.NotSaved("Task needs to be saved to add annotation")
510
511         args = [self['uuid'], 'annotate', annotation]
512         self.warrior.execute_command(args)
513         self.refresh(only_fields=['annotations'])
514
515     def remove_annotation(self, annotation):
516         if not self.saved:
517             raise Task.NotSaved("Task needs to be saved to remove annotation")
518
519         if isinstance(annotation, TaskAnnotation):
520             annotation = annotation['description']
521         args = [self['uuid'], 'denotate', annotation]
522         self.warrior.execute_command(args)
523         self.refresh(only_fields=['annotations'])
524
525     def _get_modified_fields_as_args(self):
526         args = []
527
528         def add_field(field):
529             # Add the output of format_field method to args list (defaults to
530             # field:value)
531             serialized_value = self._serialize(field, self._data[field])
532
533             # Empty values should not be enclosed in quotation marks, see
534             # TW-1510
535             if serialized_value is '':
536                 escaped_serialized_value = ''
537             else:
538                 escaped_serialized_value = "'{0}'".format(serialized_value)
539
540             format_default = lambda: "{0}:{1}".format(field,
541                                                       escaped_serialized_value)
542
543             format_func = getattr(self, 'format_{0}'.format(field),
544                                   format_default)
545
546             args.append(format_func())
547
548         # If we're modifying saved task, simply pass on all modified fields
549         if self.saved:
550             for field in self._modified_fields:
551                 add_field(field)
552         # For new tasks, pass all fields that make sense
553         else:
554             for field in self._data.keys():
555                 if field in self.read_only_fields:
556                     continue
557                 add_field(field)
558
559         return args
560
561     def refresh(self, only_fields=[]):
562         # Raise error when trying to refresh a task that has not been saved
563         if not self.saved:
564             raise Task.NotSaved("Task needs to be saved to be refreshed")
565
566         # We need to use ID as backup for uuid here for the refreshes
567         # of newly saved tasks. Any other place in the code is fine
568         # with using UUID only.
569         args = [self['uuid'] or self['id'], 'export']
570         new_data = json.loads(self.warrior.execute_command(args)[0])
571         if only_fields:
572             to_update = dict(
573                 [(k, new_data.get(k)) for k in only_fields])
574             self._update_data(to_update, update_original=True)
575         else:
576             self._load_data(new_data)
577
578 class TaskFilter(SerializingObject):
579     """
580     A set of parameters to filter the task list with.
581     """
582
583     def __init__(self, filter_params=[]):
584         self.filter_params = filter_params
585
586     def add_filter(self, filter_str):
587         self.filter_params.append(filter_str)
588
589     def add_filter_param(self, key, value):
590         key = key.replace('__', '.')
591
592         # Replace the value with empty string, since that is the
593         # convention in TW for empty values
594         attribute_key = key.split('.')[0]
595
596         # Since this is user input, we need to normalize datetime
597         # objects
598         if (isinstance(value, datetime.datetime) or
599             isinstance(value, datetime.date)):
600             value = self.normalize_datetime(value)
601
602         value = self._serialize(attribute_key, value)
603
604         # If we are filtering by uuid:, do not use uuid keyword
605         # due to TW-1452 bug
606         if key == 'uuid':
607             self.filter_params.insert(0, value)
608         else:
609             # Surround value with aphostrophes unless it's a empty string
610             value = "'%s'" % value if value else ''
611
612             # We enforce equality match by using 'is' (or 'none') modifier
613             # Without using this syntax, filter fails due to TW-1479
614             modifier = '.is' if value else '.none'
615             key = key + modifier if '.' not in key else key
616
617             self.filter_params.append("{0}:{1}".format(key, value))
618
619     def get_filter_params(self):
620         return [f for f in self.filter_params if f]
621
622     def clone(self):
623         c = self.__class__()
624         c.filter_params = list(self.filter_params)
625         return c
626
627
628 class TaskQuerySet(object):
629     """
630     Represents a lazy lookup for a task objects.
631     """
632
633     def __init__(self, warrior=None, filter_obj=None):
634         self.warrior = warrior
635         self._result_cache = None
636         self.filter_obj = filter_obj or TaskFilter()
637
638     def __deepcopy__(self, memo):
639         """
640         Deep copy of a QuerySet doesn't populate the cache
641         """
642         obj = self.__class__()
643         for k, v in self.__dict__.items():
644             if k in ('_iter', '_result_cache'):
645                 obj.__dict__[k] = None
646             else:
647                 obj.__dict__[k] = copy.deepcopy(v, memo)
648         return obj
649
650     def __repr__(self):
651         data = list(self[:REPR_OUTPUT_SIZE + 1])
652         if len(data) > REPR_OUTPUT_SIZE:
653             data[-1] = "...(remaining elements truncated)..."
654         return repr(data)
655
656     def __len__(self):
657         if self._result_cache is None:
658             self._result_cache = list(self)
659         return len(self._result_cache)
660
661     def __iter__(self):
662         if self._result_cache is None:
663             self._result_cache = self._execute()
664         return iter(self._result_cache)
665
666     def __getitem__(self, k):
667         if self._result_cache is None:
668             self._result_cache = list(self)
669         return self._result_cache.__getitem__(k)
670
671     def __bool__(self):
672         if self._result_cache is not None:
673             return bool(self._result_cache)
674         try:
675             next(iter(self))
676         except StopIteration:
677             return False
678         return True
679
680     def __nonzero__(self):
681         return type(self).__bool__(self)
682
683     def _clone(self, klass=None, **kwargs):
684         if klass is None:
685             klass = self.__class__
686         filter_obj = self.filter_obj.clone()
687         c = klass(warrior=self.warrior, filter_obj=filter_obj)
688         c.__dict__.update(kwargs)
689         return c
690
691     def _execute(self):
692         """
693         Fetch the tasks which match the current filters.
694         """
695         return self.warrior.filter_tasks(self.filter_obj)
696
697     def all(self):
698         """
699         Returns a new TaskQuerySet that is a copy of the current one.
700         """
701         return self._clone()
702
703     def pending(self):
704         return self.filter(status=PENDING)
705
706     def completed(self):
707         return self.filter(status=COMPLETED)
708
709     def filter(self, *args, **kwargs):
710         """
711         Returns a new TaskQuerySet with the given filters added.
712         """
713         clone = self._clone()
714         for f in args:
715             clone.filter_obj.add_filter(f)
716         for key, value in kwargs.items():
717             clone.filter_obj.add_filter_param(key, value)
718         return clone
719
720     def get(self, **kwargs):
721         """
722         Performs the query and returns a single object matching the given
723         keyword arguments.
724         """
725         clone = self.filter(**kwargs)
726         num = len(clone)
727         if num == 1:
728             return clone._result_cache[0]
729         if not num:
730             raise Task.DoesNotExist(
731                 'Task matching query does not exist. '
732                 'Lookup parameters were {0}'.format(kwargs))
733         raise ValueError(
734             'get() returned more than one Task -- it returned {0}! '
735             'Lookup parameters were {1}'.format(num, kwargs))
736
737
738 class TaskWarrior(object):
739     def __init__(self, data_location='~/.task', create=True):
740         data_location = os.path.expanduser(data_location)
741         if create and not os.path.exists(data_location):
742             os.makedirs(data_location)
743         self.config = {
744             'data.location': os.path.expanduser(data_location),
745             'confirmation': 'no',
746             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
747             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
748         }
749         self.tasks = TaskQuerySet(self)
750         self.version = self._get_version()
751
752     def _get_command_args(self, args, config_override={}):
753         command_args = ['task', 'rc:/']
754         config = self.config.copy()
755         config.update(config_override)
756         for item in config.items():
757             command_args.append('rc.{0}={1}'.format(*item))
758         command_args.extend(map(str, args))
759         return command_args
760
761     def _get_version(self):
762         p = subprocess.Popen(
763                 ['task', '--version'],
764                 stdout=subprocess.PIPE,
765                 stderr=subprocess.PIPE)
766         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
767         return stdout.strip('\n')
768
769     def execute_command(self, args, config_override={}):
770         command_args = self._get_command_args(
771             args, config_override=config_override)
772         logger.debug(' '.join(command_args))
773         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
774                              stderr=subprocess.PIPE)
775         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
776         if p.returncode:
777             if stderr.strip():
778                 error_msg = stderr.strip().splitlines()[-1]
779             else:
780                 error_msg = stdout.strip()
781             raise TaskWarriorException(error_msg)
782         return stdout.strip().split('\n')
783
784     def filter_tasks(self, filter_obj):
785         args = ['export', '--'] + filter_obj.get_filter_params()
786         tasks = []
787         for line in self.execute_command(args):
788             if line:
789                 data = line.strip(',')
790                 try:
791                     filtered_task = Task(self)
792                     filtered_task._load_data(json.loads(data))
793                     tasks.append(filtered_task)
794                 except ValueError:
795                     raise TaskWarriorException('Invalid JSON: %s' % data)
796         return tasks
797
798     def merge_with(self, path, push=False):
799         path = path.rstrip('/') + '/'
800         self.execute_command(['merge', path], config_override={
801             'merge.autopush': 'yes' if push else 'no',
802         })
803
804     def undo(self):
805         self.execute_command(['undo'])