]> 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:

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