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

TaskWarrior: Only strip trailing whitespace from output
[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 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
15 REPR_OUTPUT_SIZE = 10
16 PENDING = 'pending'
17 COMPLETED = 'completed'
18
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')
25
26 logger = logging.getLogger(__name__)
27 local_zone = tzlocal.get_localzone()
28
29
30 class TaskWarriorException(Exception):
31     pass
32
33
34 class ReadOnlyDictView(object):
35     """
36     Provides simplified read-only view upon dict object.
37     """
38
39     def __init__(self, viewed_dict):
40         self.viewed_dict = viewed_dict
41
42     def __getitem__(self, key):
43         return copy.deepcopy(self.viewed_dict.__getitem__(key))
44
45     def __contains__(self, k):
46         return self.viewed_dict.__contains__(k)
47
48     def __iter__(self):
49         for value in self.viewed_dict:
50             yield copy.deepcopy(value)
51
52     def __len__(self):
53         return len(self.viewed_dict)
54
55     def get(self, key, default=None):
56         return copy.deepcopy(self.viewed_dict.get(key, default))
57
58     def items(self):
59         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
60
61     def values(self):
62         return [copy.deepcopy(v) for v in self.viewed_dict.values()]
63
64
65 class SerializingObject(object):
66     """
67     Common ancestor for TaskResource & TaskFilter, since they both
68     need to serialize arguments.
69
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
74
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.
81
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
89         to raise ValueError.
90     """
91
92     def __init__(self, warrior):
93         self.warrior = warrior
94
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)
99
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)
104
105     def _normalize(self, key, value):
106         """
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.
110         """
111
112         # None value should not be converted by normalizer
113         if value is None:
114             return None
115
116         normalize_func = getattr(self, 'normalize_{0}'.format(key),
117                                  lambda x: x)
118
119         return normalize_func(value)
120
121     def timestamp_serializer(self, date):
122         if not date:
123             return ''
124
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)
128
129         return date.strftime(DATE_FORMAT)
130
131     def timestamp_deserializer(self, date_str):
132         if not date_str:
133             return None
134
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)
139
140     def serialize_entry(self, value):
141         return self.timestamp_serializer(value)
142
143     def deserialize_entry(self, value):
144         return self.timestamp_deserializer(value)
145
146     def normalize_entry(self, value):
147         return self.datetime_normalizer(value)
148
149     def serialize_modified(self, value):
150         return self.timestamp_serializer(value)
151
152     def deserialize_modified(self, value):
153         return self.timestamp_deserializer(value)
154
155     def normalize_modified(self, value):
156         return self.datetime_normalizer(value)
157
158     def serialize_start(self, value):
159         return self.timestamp_serializer(value)
160
161     def deserialize_start(self, value):
162         return self.timestamp_deserializer(value)
163
164     def normalize_start(self, value):
165         return self.datetime_normalizer(value)
166
167     def serialize_end(self, value):
168         return self.timestamp_serializer(value)
169
170     def deserialize_end(self, value):
171         return self.timestamp_deserializer(value)
172
173     def normalize_end(self, value):
174         return self.datetime_normalizer(value)
175
176     def serialize_due(self, value):
177         return self.timestamp_serializer(value)
178
179     def deserialize_due(self, value):
180         return self.timestamp_deserializer(value)
181
182     def normalize_due(self, value):
183         return self.datetime_normalizer(value)
184
185     def serialize_scheduled(self, value):
186         return self.timestamp_serializer(value)
187
188     def deserialize_scheduled(self, value):
189         return self.timestamp_deserializer(value)
190
191     def normalize_scheduled(self, value):
192         return self.datetime_normalizer(value)
193
194     def serialize_until(self, value):
195         return self.timestamp_serializer(value)
196
197     def deserialize_until(self, value):
198         return self.timestamp_deserializer(value)
199
200     def normalize_until(self, value):
201         return self.datetime_normalizer(value)
202
203     def serialize_wait(self, value):
204         return self.timestamp_serializer(value)
205
206     def deserialize_wait(self, value):
207         return self.timestamp_deserializer(value)
208
209     def normalize_wait(self, value):
210         return self.datetime_normalizer(value)
211
212     def serialize_annotations(self, value):
213         value = value if value is not None else []
214
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 ''
220
221     def deserialize_annotations(self, data):
222         return [TaskAnnotation(self, d) for d in data] if data else []
223
224     def serialize_tags(self, tags):
225         return ','.join(tags) if tags else ''
226
227     def deserialize_tags(self, tags):
228         if isinstance(tags, six.string_types):
229             return tags.split(',') if tags else []
230         return tags or []
231
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)
236
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)
241
242     def datetime_normalizer(self, value):
243         """
244         Normalizes date/datetime value (considered to come from user input)
245         to localized datetime value. Following conversions happen:
246
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)
250         """
251
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)
261             else:
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.
264                 localized = value
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
269             args = value.split()
270             result = self.warrior.execute_command(['calc'] + args)
271             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
272             localized = local_zone.localize(naive)
273         else:
274             raise ValueError("Provided value could not be converted to "
275                              "datetime, its type is not supported: {}"
276                              .format(type(value)))
277
278         return localized
279
280     def normalize_uuid(self, value):
281         # Enforce sane UUID
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))
285
286         return value
287
288
289 class TaskResource(SerializingObject):
290     read_only_fields = []
291
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)
298
299     def _update_data(self, data, update_original=False):
300         """
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.
304         """
305         self._data.update(dict((key, self._deserialize(key, value))
306                                for key, value in data.items()))
307
308         if update_original:
309             self._original_data = copy.deepcopy(self._data)
310
311
312     def __getitem__(self, key):
313         # This is a workaround to make TaskResource non-iterable
314         # over simple index-based iteration
315         try:
316             int(key)
317             raise StopIteration
318         except ValueError:
319             pass
320
321         if key not in self._data:
322             self._data[key] = self._deserialize(key, None)
323
324         return self._data.get(key)
325
326     def __setitem__(self, key, value):
327         if key in self.read_only_fields:
328             raise RuntimeError('Field \'%s\' is read-only' % key)
329
330         # Normalize the user input before saving it
331         value = self._normalize(key, value)
332         self._data[key] = value
333
334     def __str__(self):
335         s = six.text_type(self.__unicode__())
336         if not six.PY3:
337             s = s.encode('utf-8')
338         return s
339
340     def __repr__(self):
341         return str(self)
342
343     def export_data(self):
344         """
345         Exports current data contained in the Task as JSON
346         """
347
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))
351
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=(',',':'))
357
358     @property
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)
364
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:
368                 continue
369
370             if new_value != old_value:
371                 yield key
372
373     @property
374     def modified(self):
375         return bool(list(self._modified_fields))
376
377
378 class TaskAnnotation(TaskResource):
379     read_only_fields = ['entry', 'description']
380
381     def __init__(self, task, data={}):
382         self.task = task
383         self._load_data(data)
384         super(TaskAnnotation, self).__init__(task.warrior)
385
386     def remove(self):
387         self.task.remove_annotation(self)
388
389     def __unicode__(self):
390         return self['description']
391
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
396
397     __repr__ = __unicode__
398
399
400 class Task(TaskResource):
401     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
402
403     class DoesNotExist(Exception):
404         pass
405
406     class CompletedTask(Exception):
407         """
408         Raised when the operation cannot be performed on the completed task.
409         """
410         pass
411
412     class DeletedTask(Exception):
413         """
414         Raised when the operation cannot be performed on the deleted task.
415         """
416         pass
417
418     class NotSaved(Exception):
419         """
420         Raised when the operation cannot be performed on the task, because
421         it has not been saved to TaskWarrior yet.
422         """
423         pass
424
425     @classmethod
426     def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
427         """
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
432         system.
433
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.
437
438         Input_file argument can be used to specify the input file,
439         but defaults to sys.stdin.
440         """
441
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
445
446         # Create the TaskWarrior instance if none passed
447         if warrior is None:
448             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
449             warrior = TaskWarrior(data_location=hook_parent_dir)
450
451         # TaskWarrior instance is set to None
452         task = cls(warrior)
453
454         # Load the data from the input
455         task._load_data(json.loads(input_file.readline().strip()))
456
457         # If this is a on-modify event, we are provided with additional
458         # line of input, which provides updated data
459         if modify:
460             task._update_data(json.loads(input_file.readline().strip()))
461
462         return task
463
464     def __init__(self, warrior, **kwargs):
465         super(Task, self).__init__(warrior)
466
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)
471
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
475
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)
480
481         # Provide read only access to the original data
482         self.original = ReadOnlyDictView(self._original_data)
483
484     def __unicode__(self):
485         return self['description']
486
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']
491         else:
492             # If the tasks are not saved, compare the actual instances
493             return id(self) == id(other)
494
495
496     def __hash__(self):
497         if self['uuid']:
498             # For saved Tasks, just define equality by equality of uuids
499             return self['uuid'].__hash__()
500         else:
501             # If the tasks are not saved, return hash of instance id
502             return id(self).__hash__()
503
504     @property
505     def completed(self):
506         return self['status'] == six.text_type('completed')
507
508     @property
509     def deleted(self):
510         return self['status'] == six.text_type('deleted')
511
512     @property
513     def waiting(self):
514         return self['status'] == six.text_type('waiting')
515
516     @property
517     def pending(self):
518         return self['status'] == six.text_type('pending')
519
520     @property
521     def saved(self):
522         return self['uuid'] is not None or self['id'] is not None
523
524     def serialize_depends(self, cur_dependencies):
525         # Check that all the tasks are saved
526         for task in (cur_dependencies or set()):
527             if not task.saved:
528                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
529                                     'it can be set as dependency.' % task)
530
531         return super(Task, self).serialize_depends(cur_dependencies)
532
533     def format_depends(self):
534         # We need to generate added and removed dependencies list,
535         # since Taskwarrior does not accept redefining dependencies.
536
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
540
541         old_dependencies = self._original_data.get('depends', set())
542
543         added = self['depends'] - old_dependencies
544         removed = old_dependencies - self['depends']
545
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]
550             )
551
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']
557         else:
558             return "description:'{0}'".format(self._data['description'] or '')
559
560     def delete(self):
561         if not self.saved:
562             raise Task.NotSaved("Task needs to be saved before it can be deleted")
563
564         # Refresh the status, and raise exception if the task is deleted
565         self.refresh(only_fields=['status'])
566
567         if self.deleted:
568             raise Task.DeletedTask("Task was already deleted")
569
570         self.warrior.execute_command([self['uuid'], 'delete'])
571
572         # Refresh the status again, so that we have updated info stored
573         self.refresh(only_fields=['status', 'start', 'end'])
574
575     def start(self):
576         if not self.saved:
577             raise Task.NotSaved("Task needs to be saved before it can be started")
578
579         # Refresh, and raise exception if task is already completed/deleted
580         self.refresh(only_fields=['status'])
581
582         if self.completed:
583             raise Task.CompletedTask("Cannot start a completed task")
584         elif self.deleted:
585             raise Task.DeletedTask("Deleted task cannot be started")
586
587         self.warrior.execute_command([self['uuid'], 'start'])
588
589         # Refresh the status again, so that we have updated info stored
590         self.refresh(only_fields=['status', 'start'])
591
592     def done(self):
593         if not self.saved:
594             raise Task.NotSaved("Task needs to be saved before it can be completed")
595
596         # Refresh, and raise exception if task is already completed/deleted
597         self.refresh(only_fields=['status'])
598
599         if self.completed:
600             raise Task.CompletedTask("Cannot complete a completed task")
601         elif self.deleted:
602             raise Task.DeletedTask("Deleted task cannot be completed")
603
604         self.warrior.execute_command([self['uuid'], 'done'])
605
606         # Refresh the status again, so that we have updated info stored
607         self.refresh(only_fields=['status', 'start', 'end'])
608
609     def save(self):
610         if self.saved and not self.modified:
611             return
612
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)
616
617         # Parse out the new ID, if the task is being added for the first time
618         if not self.saved:
619             id_lines = [l for l in output if l.startswith('Created task ')]
620
621             # Complain loudly if it seems that more tasks were created
622             # Should not happen
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))
626
627             # Circumvent the ID storage, since ID is considered read-only
628             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
629
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
633         self.refresh()
634
635     def add_annotation(self, annotation):
636         if not self.saved:
637             raise Task.NotSaved("Task needs to be saved to add annotation")
638
639         args = [self['uuid'], 'annotate', annotation]
640         self.warrior.execute_command(args)
641         self.refresh(only_fields=['annotations'])
642
643     def remove_annotation(self, annotation):
644         if not self.saved:
645             raise Task.NotSaved("Task needs to be saved to remove annotation")
646
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'])
652
653     def _get_modified_fields_as_args(self):
654         args = []
655
656         def add_field(field):
657             # Add the output of format_field method to args list (defaults to
658             # field:value)
659             serialized_value = self._serialize(field, self._data[field])
660
661             # Empty values should not be enclosed in quotation marks, see
662             # TW-1510
663             if serialized_value is '':
664                 escaped_serialized_value = ''
665             else:
666                 escaped_serialized_value = "'{0}'".format(serialized_value)
667
668             format_default = lambda: "{0}:{1}".format(field,
669                                                       escaped_serialized_value)
670
671             format_func = getattr(self, 'format_{0}'.format(field),
672                                   format_default)
673
674             args.append(format_func())
675
676         # If we're modifying saved task, simply pass on all modified fields
677         if self.saved:
678             for field in self._modified_fields:
679                 add_field(field)
680         # For new tasks, pass all fields that make sense
681         else:
682             for field in self._data.keys():
683                 if field in self.read_only_fields:
684                     continue
685                 add_field(field)
686
687         return args
688
689     def refresh(self, only_fields=[]):
690         # Raise error when trying to refresh a task that has not been saved
691         if not self.saved:
692             raise Task.NotSaved("Task needs to be saved to be refreshed")
693
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])
699         if only_fields:
700             to_update = dict(
701                 [(k, new_data.get(k)) for k in only_fields])
702             self._update_data(to_update, update_original=True)
703         else:
704             self._load_data(new_data)
705
706 class TaskFilter(SerializingObject):
707     """
708     A set of parameters to filter the task list with.
709     """
710
711     def __init__(self, warrior, filter_params=[]):
712         self.filter_params = filter_params
713         super(TaskFilter, self).__init__(warrior)
714
715     def add_filter(self, filter_str):
716         self.filter_params.append(filter_str)
717
718     def add_filter_param(self, key, value):
719         key = key.replace('__', '.')
720
721         # Replace the value with empty string, since that is the
722         # convention in TW for empty values
723         attribute_key = key.split('.')[0]
724
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)
728
729         # If we are filtering by uuid:, do not use uuid keyword
730         # due to TW-1452 bug
731         if key == 'uuid':
732             self.filter_params.insert(0, value)
733         else:
734             # Surround value with aphostrophes unless it's a empty string
735             value = "'%s'" % value if value else ''
736
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
741
742             self.filter_params.append("{0}:{1}".format(key, value))
743
744     def get_filter_params(self):
745         return [f for f in self.filter_params if f]
746
747     def clone(self):
748         c = self.__class__(self.warrior)
749         c.filter_params = list(self.filter_params)
750         return c
751
752
753 class TaskQuerySet(object):
754     """
755     Represents a lazy lookup for a task objects.
756     """
757
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)
762
763     def __deepcopy__(self, memo):
764         """
765         Deep copy of a QuerySet doesn't populate the cache
766         """
767         obj = self.__class__()
768         for k, v in self.__dict__.items():
769             if k in ('_iter', '_result_cache'):
770                 obj.__dict__[k] = None
771             else:
772                 obj.__dict__[k] = copy.deepcopy(v, memo)
773         return obj
774
775     def __repr__(self):
776         data = list(self[:REPR_OUTPUT_SIZE + 1])
777         if len(data) > REPR_OUTPUT_SIZE:
778             data[-1] = "...(remaining elements truncated)..."
779         return repr(data)
780
781     def __len__(self):
782         if self._result_cache is None:
783             self._result_cache = list(self)
784         return len(self._result_cache)
785
786     def __iter__(self):
787         if self._result_cache is None:
788             self._result_cache = self._execute()
789         return iter(self._result_cache)
790
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)
795
796     def __bool__(self):
797         if self._result_cache is not None:
798             return bool(self._result_cache)
799         try:
800             next(iter(self))
801         except StopIteration:
802             return False
803         return True
804
805     def __nonzero__(self):
806         return type(self).__bool__(self)
807
808     def _clone(self, klass=None, **kwargs):
809         if klass is None:
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)
814         return c
815
816     def _execute(self):
817         """
818         Fetch the tasks which match the current filters.
819         """
820         return self.warrior.filter_tasks(self.filter_obj)
821
822     def all(self):
823         """
824         Returns a new TaskQuerySet that is a copy of the current one.
825         """
826         return self._clone()
827
828     def pending(self):
829         return self.filter(status=PENDING)
830
831     def completed(self):
832         return self.filter(status=COMPLETED)
833
834     def filter(self, *args, **kwargs):
835         """
836         Returns a new TaskQuerySet with the given filters added.
837         """
838         clone = self._clone()
839         for f in args:
840             clone.filter_obj.add_filter(f)
841         for key, value in kwargs.items():
842             clone.filter_obj.add_filter_param(key, value)
843         return clone
844
845     def get(self, **kwargs):
846         """
847         Performs the query and returns a single object matching the given
848         keyword arguments.
849         """
850         clone = self.filter(**kwargs)
851         num = len(clone)
852         if num == 1:
853             return clone._result_cache[0]
854         if not num:
855             raise Task.DoesNotExist(
856                 'Task matching query does not exist. '
857                 'Lookup parameters were {0}'.format(kwargs))
858         raise ValueError(
859             'get() returned more than one Task -- it returned {0}! '
860             'Lookup parameters were {1}'.format(num, kwargs))
861
862
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)
867
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 = '/'
872
873         if create and not os.path.exists(data_location):
874             os.makedirs(data_location)
875
876         self.config = {
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
881         }
882         self.tasks = TaskQuerySet(self)
883         self.version = self._get_version()
884
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))
892         return command_args
893
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')
901
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:
910             if stderr.strip():
911                 error_msg = stderr.strip()
912             else:
913                 error_msg = stdout.strip()
914             raise TaskWarriorException(error_msg)
915         return stdout.rstrip().split('\n')
916
917     def enforce_recurrence(self):
918         # Run arbitrary report command which will trigger generation
919         # of recurrent tasks.
920
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)
924
925     def filter_tasks(self, filter_obj):
926         self.enforce_recurrence()
927         args = ['export', '--'] + filter_obj.get_filter_params()
928         tasks = []
929         for line in self.execute_command(args):
930             if line:
931                 data = line.strip(',')
932                 try:
933                     filtered_task = Task(self)
934                     filtered_task._load_data(json.loads(data))
935                     tasks.append(filtered_task)
936                 except ValueError:
937                     raise TaskWarriorException('Invalid JSON: %s' % data)
938         return tasks
939
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',
944         })
945
946     def undo(self):
947         self.execute_command(['undo'])