All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
1 from __future__ import print_function
12 from backends import TaskWarrior
14 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
15 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
18 COMPLETED = 'completed'
20 logger = logging.getLogger(__name__)
21 local_zone = tzlocal.get_localzone()
24 class TaskWarriorException(Exception):
28 class ReadOnlyDictView(object):
30 Provides simplified read-only view upon dict object.
33 def __init__(self, viewed_dict):
34 self.viewed_dict = viewed_dict
36 def __getitem__(self, key):
37 return copy.deepcopy(self.viewed_dict.__getitem__(key))
39 def __contains__(self, k):
40 return self.viewed_dict.__contains__(k)
43 for value in self.viewed_dict:
44 yield copy.deepcopy(value)
47 return len(self.viewed_dict)
49 def get(self, key, default=None):
50 return copy.deepcopy(self.viewed_dict.get(key, default))
53 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
56 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
59 class SerializingObject(object):
61 Common ancestor for TaskResource & TaskFilter, since they both
62 need to serialize arguments.
64 Serializing method should hold the following contract:
65 - any empty value (meaning removal of the attribute)
66 is deserialized into a empty string
67 - None denotes a empty value for any attribute
69 Deserializing method should hold the following contract:
70 - None denotes a empty value for any attribute (however,
71 this is here as a safeguard, TaskWarrior currently does
72 not export empty-valued attributes) if the attribute
73 is not iterable (e.g. list or set), in which case
74 a empty iterable should be used.
76 Normalizing methods should hold the following contract:
77 - They are used to validate and normalize the user input.
78 Any attribute value that comes from the user (during Task
79 initialization, assignign values to Task attributes, or
80 filtering by user-provided values of attributes) is first
81 validated and normalized using the normalize_{key} method.
82 - If validation or normalization fails, normalizer is expected
86 def __init__(self, warrior):
87 self.warrior = warrior
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)
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)
99 def _normalize(self, key, value):
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.
106 # None value should not be converted by normalizer
110 normalize_func = getattr(self, 'normalize_{0}'.format(key),
113 return normalize_func(value)
115 def timestamp_serializer(self, date):
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)
123 return date.strftime(DATE_FORMAT)
125 def timestamp_deserializer(self, date_str):
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)
134 def serialize_entry(self, value):
135 return self.timestamp_serializer(value)
137 def deserialize_entry(self, value):
138 return self.timestamp_deserializer(value)
140 def normalize_entry(self, value):
141 return self.datetime_normalizer(value)
143 def serialize_modified(self, value):
144 return self.timestamp_serializer(value)
146 def deserialize_modified(self, value):
147 return self.timestamp_deserializer(value)
149 def normalize_modified(self, value):
150 return self.datetime_normalizer(value)
152 def serialize_start(self, value):
153 return self.timestamp_serializer(value)
155 def deserialize_start(self, value):
156 return self.timestamp_deserializer(value)
158 def normalize_start(self, value):
159 return self.datetime_normalizer(value)
161 def serialize_end(self, value):
162 return self.timestamp_serializer(value)
164 def deserialize_end(self, value):
165 return self.timestamp_deserializer(value)
167 def normalize_end(self, value):
168 return self.datetime_normalizer(value)
170 def serialize_due(self, value):
171 return self.timestamp_serializer(value)
173 def deserialize_due(self, value):
174 return self.timestamp_deserializer(value)
176 def normalize_due(self, value):
177 return self.datetime_normalizer(value)
179 def serialize_scheduled(self, value):
180 return self.timestamp_serializer(value)
182 def deserialize_scheduled(self, value):
183 return self.timestamp_deserializer(value)
185 def normalize_scheduled(self, value):
186 return self.datetime_normalizer(value)
188 def serialize_until(self, value):
189 return self.timestamp_serializer(value)
191 def deserialize_until(self, value):
192 return self.timestamp_deserializer(value)
194 def normalize_until(self, value):
195 return self.datetime_normalizer(value)
197 def serialize_wait(self, value):
198 return self.timestamp_serializer(value)
200 def deserialize_wait(self, value):
201 return self.timestamp_deserializer(value)
203 def normalize_wait(self, value):
204 return self.datetime_normalizer(value)
206 def serialize_annotations(self, value):
207 value = value if value is not None else []
209 # This may seem weird, but it's correct, we want to export
210 # a list of dicts as serialized value
211 serialized_annotations = [json.loads(annotation.export_data())
212 for annotation in value]
213 return serialized_annotations if serialized_annotations else ''
215 def deserialize_annotations(self, data):
216 return [TaskAnnotation(self, d) for d in data] if data else []
218 def serialize_tags(self, tags):
219 return ','.join(tags) if tags else ''
221 def deserialize_tags(self, tags):
222 if isinstance(tags, six.string_types):
223 return tags.split(',') if tags else []
226 def serialize_depends(self, value):
227 # Return the list of uuids
228 value = value if value is not None else set()
229 return ','.join(task['uuid'] for task in value)
231 def deserialize_depends(self, raw_uuids):
232 raw_uuids = raw_uuids or [] # Convert None to empty list
234 # TW 2.4.4 encodes list of dependencies as a single string
235 if type(raw_uuids) is not list:
236 uuids = raw_uuids.split(',')
237 # TW 2.4.5 and later exports them as a list, no conversion needed
241 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
243 def datetime_normalizer(self, value):
245 Normalizes date/datetime value (considered to come from user input)
246 to localized datetime value. Following conversions happen:
248 naive date -> localized datetime with the same date, and time=midnight
249 naive datetime -> localized datetime with the same value
250 localized datetime -> localized datetime (no conversion)
253 if (isinstance(value, datetime.date)
254 and not isinstance(value, datetime.datetime)):
255 # Convert to local midnight
256 value_full = datetime.datetime.combine(value, datetime.time.min)
257 localized = local_zone.localize(value_full)
258 elif isinstance(value, datetime.datetime):
259 if value.tzinfo is None:
260 # Convert to localized datetime object
261 localized = local_zone.localize(value)
263 # If the value is already localized, there is no need to change
264 # time zone at this point. Also None is a valid value too.
266 elif (isinstance(value, six.string_types)
267 and self.warrior.version >= VERSION_2_4_0):
268 # For strings, use 'task calc' to evaluate the string to datetime
269 # available since TW 2.4.0
271 result = self.warrior.execute_command(['calc'] + args)
272 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
273 localized = local_zone.localize(naive)
275 raise ValueError("Provided value could not be converted to "
276 "datetime, its type is not supported: {}"
277 .format(type(value)))
281 def normalize_uuid(self, value):
283 if not isinstance(value, six.string_types) or value == '':
284 raise ValueError("UUID must be a valid non-empty string, "
285 "not: {}".format(value))
290 class TaskResource(SerializingObject):
291 read_only_fields = []
293 def _load_data(self, data):
294 self._data = dict((key, self._deserialize(key, value))
295 for key, value in data.items())
296 # We need to use a copy for original data, so that changes
297 # are not propagated.
298 self._original_data = copy.deepcopy(self._data)
300 def _update_data(self, data, update_original=False, remove_missing=False):
302 Low level update of the internal _data dict. Data which are coming as
303 updates should already be serialized. If update_original is True, the
304 original_data dict is updated as well.
306 self._data.update(dict((key, self._deserialize(key, value))
307 for key, value in data.items()))
309 # In certain situations, we want to treat missing keys as removals
311 for key in set(self._data.keys()) - set(data.keys()):
312 self._data[key] = None
315 self._original_data = copy.deepcopy(self._data)
318 def __getitem__(self, key):
319 # This is a workaround to make TaskResource non-iterable
320 # over simple index-based iteration
327 if key not in self._data:
328 self._data[key] = self._deserialize(key, None)
330 return self._data.get(key)
332 def __setitem__(self, key, value):
333 if key in self.read_only_fields:
334 raise RuntimeError('Field \'%s\' is read-only' % key)
336 # Normalize the user input before saving it
337 value = self._normalize(key, value)
338 self._data[key] = value
341 s = six.text_type(self.__unicode__())
343 s = s.encode('utf-8')
349 def export_data(self):
351 Exports current data contained in the Task as JSON
354 # We need to remove spaces for TW-1504, use custom separators
355 data_tuples = ((key, self._serialize(key, value))
356 for key, value in six.iteritems(self._data))
358 # Empty string denotes empty serialized value, we do not want
359 # to pass that to TaskWarrior.
360 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
361 data = dict(data_tuples)
362 return json.dumps(data, separators=(',',':'))
365 def _modified_fields(self):
366 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
367 for key in writable_fields:
368 new_value = self._data.get(key)
369 old_value = self._original_data.get(key)
371 # Make sure not to mark data removal as modified field if the
372 # field originally had some empty value
373 if key in self._data and not new_value and not old_value:
376 if new_value != old_value:
381 return bool(list(self._modified_fields))
384 class TaskAnnotation(TaskResource):
385 read_only_fields = ['entry', 'description']
387 def __init__(self, task, data=None):
389 self._load_data(data or dict())
390 super(TaskAnnotation, self).__init__(task.warrior)
393 self.task.remove_annotation(self)
395 def __unicode__(self):
396 return self['description']
398 def __eq__(self, other):
399 # consider 2 annotations equal if they belong to the same task, and
400 # their data dics are the same
401 return self.task == other.task and self._data == other._data
403 __repr__ = __unicode__
406 class Task(TaskResource):
407 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
409 class DoesNotExist(Exception):
412 class CompletedTask(Exception):
414 Raised when the operation cannot be performed on the completed task.
418 class DeletedTask(Exception):
420 Raised when the operation cannot be performed on the deleted task.
424 class ActiveTask(Exception):
426 Raised when the operation cannot be performed on the active task.
430 class InactiveTask(Exception):
432 Raised when the operation cannot be performed on an inactive task.
436 class NotSaved(Exception):
438 Raised when the operation cannot be performed on the task, because
439 it has not been saved to TaskWarrior yet.
444 def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
446 Creates a Task object, directly from the stdin, by reading one line.
447 If modify=True, two lines are used, first line interpreted as the
448 original state of the Task object, and second line as its new,
449 modified value. This is consistent with the TaskWarrior's hook
452 Object created by this method should not be saved, deleted
453 or refreshed, as t could create a infinite loop. For this
454 reason, TaskWarrior instance is set to None.
456 Input_file argument can be used to specify the input file,
457 but defaults to sys.stdin.
460 # Detect the hook type if not given directly
461 name = os.path.basename(sys.argv[0])
462 modify = name.startswith('on-modify') if modify is None else modify
464 # Create the TaskWarrior instance if none passed
466 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
467 warrior = TaskWarrior(data_location=hook_parent_dir)
469 # TaskWarrior instance is set to None
472 # Load the data from the input
473 task._load_data(json.loads(input_file.readline().strip()))
475 # If this is a on-modify event, we are provided with additional
476 # line of input, which provides updated data
478 task._update_data(json.loads(input_file.readline().strip()),
483 def __init__(self, warrior, **kwargs):
484 super(Task, self).__init__(warrior)
486 # Check that user is not able to set read-only value in __init__
487 for key in kwargs.keys():
488 if key in self.read_only_fields:
489 raise RuntimeError('Field \'%s\' is read-only' % key)
491 # We serialize the data in kwargs so that users of the library
492 # do not have to pass different data formats via __setitem__ and
493 # __init__ methods, that would be confusing
495 # Rather unfortunate syntax due to python2.6 comaptiblity
496 self._data = dict((key, self._normalize(key, value))
497 for (key, value) in six.iteritems(kwargs))
498 self._original_data = copy.deepcopy(self._data)
500 # Provide read only access to the original data
501 self.original = ReadOnlyDictView(self._original_data)
503 def __unicode__(self):
504 return self['description']
506 def __eq__(self, other):
507 if self['uuid'] and other['uuid']:
508 # For saved Tasks, just define equality by equality of uuids
509 return self['uuid'] == other['uuid']
511 # If the tasks are not saved, compare the actual instances
512 return id(self) == id(other)
517 # For saved Tasks, just define equality by equality of uuids
518 return self['uuid'].__hash__()
520 # If the tasks are not saved, return hash of instance id
521 return id(self).__hash__()
525 return self['status'] == six.text_type('completed')
529 return self['status'] == six.text_type('deleted')
533 return self['status'] == six.text_type('waiting')
537 return self['status'] == six.text_type('pending')
541 return self['start'] is not None
545 return self['uuid'] is not None or self['id'] is not None
547 def serialize_depends(self, cur_dependencies):
548 # Check that all the tasks are saved
549 for task in (cur_dependencies or set()):
551 raise Task.NotSaved('Task \'%s\' needs to be saved before '
552 'it can be set as dependency.' % task)
554 return super(Task, self).serialize_depends(cur_dependencies)
556 def format_depends(self):
557 # We need to generate added and removed dependencies list,
558 # since Taskwarrior does not accept redefining dependencies.
560 # This cannot be part of serialize_depends, since we need
561 # to keep a list of all depedencies in the _data dictionary,
562 # not just currently added/removed ones
564 old_dependencies = self._original_data.get('depends', set())
566 added = self['depends'] - old_dependencies
567 removed = old_dependencies - self['depends']
569 # Removed dependencies need to be prefixed with '-'
570 return 'depends:' + ','.join(
571 [t['uuid'] for t in added] +
572 ['-' + t['uuid'] for t in removed]
575 def format_description(self):
576 # Task version older than 2.4.0 ignores first word of the
577 # task description if description: prefix is used
578 if self.warrior.version < VERSION_2_4_0:
579 return self._data['description']
581 return six.u("description:'{0}'").format(self._data['description'] or '')
585 raise Task.NotSaved("Task needs to be saved before it can be deleted")
587 # Refresh the status, and raise exception if the task is deleted
588 self.refresh(only_fields=['status'])
591 raise Task.DeletedTask("Task was already deleted")
593 self.warrior.execute_command([self['uuid'], 'delete'])
595 # Refresh the status again, so that we have updated info stored
596 self.refresh(only_fields=['status', 'start', 'end'])
600 raise Task.NotSaved("Task needs to be saved before it can be started")
602 # Refresh, and raise exception if task is already completed/deleted
603 self.refresh(only_fields=['status'])
606 raise Task.CompletedTask("Cannot start a completed task")
608 raise Task.DeletedTask("Deleted task cannot be started")
610 raise Task.ActiveTask("Task is already active")
612 self.warrior.execute_command([self['uuid'], 'start'])
614 # Refresh the status again, so that we have updated info stored
615 self.refresh(only_fields=['status', 'start'])
619 raise Task.NotSaved("Task needs to be saved before it can be stopped")
621 # Refresh, and raise exception if task is already completed/deleted
622 self.refresh(only_fields=['status'])
625 raise Task.InactiveTask("Cannot stop an inactive task")
627 self.warrior.execute_command([self['uuid'], 'stop'])
629 # Refresh the status again, so that we have updated info stored
630 self.refresh(only_fields=['status', 'start'])
634 raise Task.NotSaved("Task needs to be saved before it can be completed")
636 # Refresh, and raise exception if task is already completed/deleted
637 self.refresh(only_fields=['status'])
640 raise Task.CompletedTask("Cannot complete a completed task")
642 raise Task.DeletedTask("Deleted task cannot be completed")
644 # Older versions of TW do not stop active task at completion
645 if self.warrior.version < VERSION_2_4_0 and self.active:
648 self.warrior.execute_command([self['uuid'], 'done'])
650 # Refresh the status again, so that we have updated info stored
651 self.refresh(only_fields=['status', 'start', 'end'])
654 if self.saved and not self.modified:
657 args = [self['uuid'], 'modify'] if self.saved else ['add']
658 args.extend(self._get_modified_fields_as_args())
659 output = self.warrior.execute_command(args)
661 # Parse out the new ID, if the task is being added for the first time
663 id_lines = [l for l in output if l.startswith('Created task ')]
665 # Complain loudly if it seems that more tasks were created
667 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
668 raise TaskWarriorException("Unexpected output when creating "
669 "task: %s" % '\n'.join(id_lines))
671 # Circumvent the ID storage, since ID is considered read-only
672 identifier = id_lines[0].split(' ')[2].rstrip('.')
674 # Identifier can be either ID or UUID for completed tasks
676 self._data['id'] = int(identifier)
678 self._data['uuid'] = identifier
680 # Refreshing is very important here, as not only modification time
681 # is updated, but arbitrary attribute may have changed due hooks
682 # altering the data before saving
683 self.refresh(after_save=True)
685 def add_annotation(self, annotation):
687 raise Task.NotSaved("Task needs to be saved to add annotation")
689 args = [self['uuid'], 'annotate', annotation]
690 self.warrior.execute_command(args)
691 self.refresh(only_fields=['annotations'])
693 def remove_annotation(self, annotation):
695 raise Task.NotSaved("Task needs to be saved to remove annotation")
697 if isinstance(annotation, TaskAnnotation):
698 annotation = annotation['description']
699 args = [self['uuid'], 'denotate', annotation]
700 self.warrior.execute_command(args)
701 self.refresh(only_fields=['annotations'])
703 def _get_modified_fields_as_args(self):
706 def add_field(field):
707 # Add the output of format_field method to args list (defaults to
709 serialized_value = self._serialize(field, self._data[field])
711 # Empty values should not be enclosed in quotation marks, see
713 if serialized_value is '':
714 escaped_serialized_value = ''
716 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
718 format_default = lambda: six.u("{0}:{1}").format(field,
719 escaped_serialized_value)
721 format_func = getattr(self, 'format_{0}'.format(field),
724 args.append(format_func())
726 # If we're modifying saved task, simply pass on all modified fields
728 for field in self._modified_fields:
730 # For new tasks, pass all fields that make sense
732 for field in self._data.keys():
733 if field in self.read_only_fields:
739 def refresh(self, only_fields=None, after_save=False):
740 # Raise error when trying to refresh a task that has not been saved
742 raise Task.NotSaved("Task needs to be saved to be refreshed")
744 # We need to use ID as backup for uuid here for the refreshes
745 # of newly saved tasks. Any other place in the code is fine
746 # with using UUID only.
747 args = [self['uuid'] or self['id'], 'export']
748 output = self.warrior.execute_command(args)
751 return len(output) == 1 and output[0].startswith('{')
753 # For older TW versions attempt to uniquely locate the task
754 # using the data we have if it has been just saved.
755 # This can happen when adding a completed task on older TW versions.
756 if (not valid(output) and self.warrior.version < VERSION_2_4_5
759 # Make a copy, removing ID and UUID. It's most likely invalid
760 # (ID 0) if it failed to match a unique task.
761 data = copy.deepcopy(self._data)
763 data.pop('uuid', None)
765 taskfilter = TaskFilter(self.warrior)
766 for key, value in data.items():
767 taskfilter.add_filter_param(key, value)
769 output = self.warrior.execute_command(['export', '--'] +
770 taskfilter.get_filter_params())
772 # If more than 1 task has been matched still, raise an exception
773 if not valid(output):
774 raise TaskWarriorException(
775 "Unique identifiers {0} with description: {1} matches "
776 "multiple tasks: {2}".format(
777 self['uuid'] or self['id'], self['description'], output)
780 new_data = json.loads(output[0])
783 [(k, new_data.get(k)) for k in only_fields])
784 self._update_data(to_update, update_original=True)
786 self._load_data(new_data)
788 class TaskFilter(SerializingObject):
790 A set of parameters to filter the task list with.
793 def __init__(self, warrior, filter_params=None):
794 self.filter_params = filter_params or []
795 super(TaskFilter, self).__init__(warrior)
797 def add_filter(self, filter_str):
798 self.filter_params.append(filter_str)
800 def add_filter_param(self, key, value):
801 key = key.replace('__', '.')
803 # Replace the value with empty string, since that is the
804 # convention in TW for empty values
805 attribute_key = key.split('.')[0]
807 # Since this is user input, we need to normalize before we serialize
808 value = self._normalize(attribute_key, value)
809 value = self._serialize(attribute_key, value)
811 # If we are filtering by uuid:, do not use uuid keyword
814 self.filter_params.insert(0, value)
816 # Surround value with aphostrophes unless it's a empty string
817 value = "'%s'" % value if value else ''
819 # We enforce equality match by using 'is' (or 'none') modifier
820 # Without using this syntax, filter fails due to TW-1479
821 # which is, however, fixed in 2.4.5
822 if self.warrior.version < VERSION_2_4_5:
823 modifier = '.is' if value else '.none'
824 key = key + modifier if '.' not in key else key
826 self.filter_params.append(six.u("{0}:{1}").format(key, value))
828 def get_filter_params(self):
829 return [f for f in self.filter_params if f]
832 c = self.__class__(self.warrior)
833 c.filter_params = list(self.filter_params)
837 class TaskQuerySet(object):
839 Represents a lazy lookup for a task objects.
842 def __init__(self, warrior=None, filter_obj=None):
843 self.warrior = warrior
844 self._result_cache = None
845 self.filter_obj = filter_obj or TaskFilter(warrior)
847 def __deepcopy__(self, memo):
849 Deep copy of a QuerySet doesn't populate the cache
851 obj = self.__class__()
852 for k, v in self.__dict__.items():
853 if k in ('_iter', '_result_cache'):
854 obj.__dict__[k] = None
856 obj.__dict__[k] = copy.deepcopy(v, memo)
860 data = list(self[:REPR_OUTPUT_SIZE + 1])
861 if len(data) > REPR_OUTPUT_SIZE:
862 data[-1] = "...(remaining elements truncated)..."
866 if self._result_cache is None:
867 self._result_cache = list(self)
868 return len(self._result_cache)
871 if self._result_cache is None:
872 self._result_cache = self._execute()
873 return iter(self._result_cache)
875 def __getitem__(self, k):
876 if self._result_cache is None:
877 self._result_cache = list(self)
878 return self._result_cache.__getitem__(k)
881 if self._result_cache is not None:
882 return bool(self._result_cache)
885 except StopIteration:
889 def __nonzero__(self):
890 return type(self).__bool__(self)
892 def _clone(self, klass=None, **kwargs):
894 klass = self.__class__
895 filter_obj = self.filter_obj.clone()
896 c = klass(warrior=self.warrior, filter_obj=filter_obj)
897 c.__dict__.update(kwargs)
902 Fetch the tasks which match the current filters.
904 return self.warrior.filter_tasks(self.filter_obj)
908 Returns a new TaskQuerySet that is a copy of the current one.
913 return self.filter(status=PENDING)
916 return self.filter(status=COMPLETED)
918 def filter(self, *args, **kwargs):
920 Returns a new TaskQuerySet with the given filters added.
922 clone = self._clone()
924 clone.filter_obj.add_filter(f)
925 for key, value in kwargs.items():
926 clone.filter_obj.add_filter_param(key, value)
929 def get(self, **kwargs):
931 Performs the query and returns a single object matching the given
934 clone = self.filter(**kwargs)
937 return clone._result_cache[0]
939 raise Task.DoesNotExist(
940 'Task matching query does not exist. '
941 'Lookup parameters were {0}'.format(kwargs))
943 'get() returned more than one Task -- it returned {0}! '
944 'Lookup parameters were {1}'.format(num, kwargs))