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, TaskWarriorException
 
  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 ReadOnlyDictView(object):
 
  26     Provides simplified read-only view upon dict object.
 
  29     def __init__(self, viewed_dict):
 
  30         self.viewed_dict = viewed_dict
 
  32     def __getitem__(self, key):
 
  33         return copy.deepcopy(self.viewed_dict.__getitem__(key))
 
  35     def __contains__(self, k):
 
  36         return self.viewed_dict.__contains__(k)
 
  39         for value in self.viewed_dict:
 
  40             yield copy.deepcopy(value)
 
  43         return len(self.viewed_dict)
 
  45     def get(self, key, default=None):
 
  46         return copy.deepcopy(self.viewed_dict.get(key, default))
 
  49         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
 
  52         return [copy.deepcopy(v) for v in self.viewed_dict.values()]
 
  55 class SerializingObject(object):
 
  57     Common ancestor for TaskResource & TaskFilter, since they both
 
  58     need to serialize arguments.
 
  60     Serializing method should hold the following contract:
 
  61       - any empty value (meaning removal of the attribute)
 
  62         is deserialized into a empty string
 
  63       - None denotes a empty value for any attribute
 
  65     Deserializing method should hold the following contract:
 
  66       - None denotes a empty value for any attribute (however,
 
  67         this is here as a safeguard, TaskWarrior currently does
 
  68         not export empty-valued attributes) if the attribute
 
  69         is not iterable (e.g. list or set), in which case
 
  70         a empty iterable should be used.
 
  72     Normalizing methods should hold the following contract:
 
  73       - They are used to validate and normalize the user input.
 
  74         Any attribute value that comes from the user (during Task
 
  75         initialization, assignign values to Task attributes, or
 
  76         filtering by user-provided values of attributes) is first
 
  77         validated and normalized using the normalize_{key} method.
 
  78       - If validation or normalization fails, normalizer is expected
 
  82     def __init__(self, warrior):
 
  83         self.warrior = warrior
 
  85     def _deserialize(self, key, value):
 
  86         hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
 
  87                                lambda x: x if x != '' else None)
 
  88         return hydrate_func(value)
 
  90     def _serialize(self, key, value):
 
  91         dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
 
  92                                  lambda x: x if x is not None else '')
 
  93         return dehydrate_func(value)
 
  95     def _normalize(self, key, value):
 
  97         Use normalize_<key> methods to normalize user input. Any user
 
  98         input will be normalized at the moment it is used as filter,
 
  99         or entered as a value of Task attribute.
 
 102         # None value should not be converted by normalizer
 
 106         normalize_func = getattr(self, 'normalize_{0}'.format(key),
 
 109         return normalize_func(value)
 
 111     def timestamp_serializer(self, date):
 
 115         # Any serialized timestamp should be localized, we need to
 
 116         # convert to UTC before converting to string (DATE_FORMAT uses UTC)
 
 117         date = date.astimezone(pytz.utc)
 
 119         return date.strftime(DATE_FORMAT)
 
 121     def timestamp_deserializer(self, date_str):
 
 125         # Return timestamp localized in the local zone
 
 126         naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT)
 
 127         localized_timestamp = pytz.utc.localize(naive_timestamp)
 
 128         return localized_timestamp.astimezone(local_zone)
 
 130     def serialize_entry(self, value):
 
 131         return self.timestamp_serializer(value)
 
 133     def deserialize_entry(self, value):
 
 134         return self.timestamp_deserializer(value)
 
 136     def normalize_entry(self, value):
 
 137         return self.datetime_normalizer(value)
 
 139     def serialize_modified(self, value):
 
 140         return self.timestamp_serializer(value)
 
 142     def deserialize_modified(self, value):
 
 143         return self.timestamp_deserializer(value)
 
 145     def normalize_modified(self, value):
 
 146         return self.datetime_normalizer(value)
 
 148     def serialize_start(self, value):
 
 149         return self.timestamp_serializer(value)
 
 151     def deserialize_start(self, value):
 
 152         return self.timestamp_deserializer(value)
 
 154     def normalize_start(self, value):
 
 155         return self.datetime_normalizer(value)
 
 157     def serialize_end(self, value):
 
 158         return self.timestamp_serializer(value)
 
 160     def deserialize_end(self, value):
 
 161         return self.timestamp_deserializer(value)
 
 163     def normalize_end(self, value):
 
 164         return self.datetime_normalizer(value)
 
 166     def serialize_due(self, value):
 
 167         return self.timestamp_serializer(value)
 
 169     def deserialize_due(self, value):
 
 170         return self.timestamp_deserializer(value)
 
 172     def normalize_due(self, value):
 
 173         return self.datetime_normalizer(value)
 
 175     def serialize_scheduled(self, value):
 
 176         return self.timestamp_serializer(value)
 
 178     def deserialize_scheduled(self, value):
 
 179         return self.timestamp_deserializer(value)
 
 181     def normalize_scheduled(self, value):
 
 182         return self.datetime_normalizer(value)
 
 184     def serialize_until(self, value):
 
 185         return self.timestamp_serializer(value)
 
 187     def deserialize_until(self, value):
 
 188         return self.timestamp_deserializer(value)
 
 190     def normalize_until(self, value):
 
 191         return self.datetime_normalizer(value)
 
 193     def serialize_wait(self, value):
 
 194         return self.timestamp_serializer(value)
 
 196     def deserialize_wait(self, value):
 
 197         return self.timestamp_deserializer(value)
 
 199     def normalize_wait(self, value):
 
 200         return self.datetime_normalizer(value)
 
 202     def serialize_annotations(self, value):
 
 203         value = value if value is not None else []
 
 205         # This may seem weird, but it's correct, we want to export
 
 206         # a list of dicts as serialized value
 
 207         serialized_annotations = [json.loads(annotation.export_data())
 
 208                                   for annotation in value]
 
 209         return serialized_annotations if serialized_annotations else ''
 
 211     def deserialize_annotations(self, data):
 
 212         return [TaskAnnotation(self, d) for d in data] if data else []
 
 214     def serialize_tags(self, tags):
 
 215         return ','.join(tags) if tags else ''
 
 217     def deserialize_tags(self, tags):
 
 218         if isinstance(tags, six.string_types):
 
 219             return tags.split(',') if tags else []
 
 222     def serialize_depends(self, value):
 
 223         # Return the list of uuids
 
 224         value = value if value is not None else set()
 
 225         return ','.join(task['uuid'] for task in value)
 
 227     def deserialize_depends(self, raw_uuids):
 
 228         raw_uuids = raw_uuids or []  # Convert None to empty list
 
 230         # TW 2.4.4 encodes list of dependencies as a single string
 
 231         if type(raw_uuids) is not list:
 
 232             uuids = raw_uuids.split(',')
 
 233         # TW 2.4.5 and later exports them as a list, no conversion needed
 
 237         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
 
 239     def datetime_normalizer(self, value):
 
 241         Normalizes date/datetime value (considered to come from user input)
 
 242         to localized datetime value. Following conversions happen:
 
 244         naive date -> localized datetime with the same date, and time=midnight
 
 245         naive datetime -> localized datetime with the same value
 
 246         localized datetime -> localized datetime (no conversion)
 
 249         if (isinstance(value, datetime.date)
 
 250             and not isinstance(value, datetime.datetime)):
 
 251             # Convert to local midnight
 
 252             value_full = datetime.datetime.combine(value, datetime.time.min)
 
 253             localized = local_zone.localize(value_full)
 
 254         elif isinstance(value, datetime.datetime):
 
 255             if value.tzinfo is None:
 
 256                 # Convert to localized datetime object
 
 257                 localized = local_zone.localize(value)
 
 259                 # If the value is already localized, there is no need to change
 
 260                 # time zone at this point. Also None is a valid value too.
 
 262         elif (isinstance(value, six.string_types)
 
 263                 and self.warrior.version >= VERSION_2_4_0):
 
 264             # For strings, use 'task calc' to evaluate the string to datetime
 
 265             # available since TW 2.4.0
 
 267             result = self.warrior.execute_command(['calc'] + args)
 
 268             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
 
 269             localized = local_zone.localize(naive)
 
 271             raise ValueError("Provided value could not be converted to "
 
 272                              "datetime, its type is not supported: {}"
 
 273                              .format(type(value)))
 
 277     def normalize_uuid(self, value):
 
 279         if not isinstance(value, six.string_types) or value == '':
 
 280             raise ValueError("UUID must be a valid non-empty string, "
 
 281                              "not: {}".format(value))
 
 286 class TaskResource(SerializingObject):
 
 287     read_only_fields = []
 
 289     def _load_data(self, data):
 
 290         self._data = dict((key, self._deserialize(key, value))
 
 291                           for key, value in data.items())
 
 292         # We need to use a copy for original data, so that changes
 
 293         # are not propagated.
 
 294         self._original_data = copy.deepcopy(self._data)
 
 296     def _update_data(self, data, update_original=False, remove_missing=False):
 
 298         Low level update of the internal _data dict. Data which are coming as
 
 299         updates should already be serialized. If update_original is True, the
 
 300         original_data dict is updated as well.
 
 302         self._data.update(dict((key, self._deserialize(key, value))
 
 303                                for key, value in data.items()))
 
 305         # In certain situations, we want to treat missing keys as removals
 
 307             for key in set(self._data.keys()) - set(data.keys()):
 
 308                 self._data[key] = None
 
 311             self._original_data = copy.deepcopy(self._data)
 
 314     def __getitem__(self, key):
 
 315         # This is a workaround to make TaskResource non-iterable
 
 316         # over simple index-based iteration
 
 323         if key not in self._data:
 
 324             self._data[key] = self._deserialize(key, None)
 
 326         return self._data.get(key)
 
 328     def __setitem__(self, key, value):
 
 329         if key in self.read_only_fields:
 
 330             raise RuntimeError('Field \'%s\' is read-only' % key)
 
 332         # Normalize the user input before saving it
 
 333         value = self._normalize(key, value)
 
 334         self._data[key] = value
 
 337         s = six.text_type(self.__unicode__())
 
 339             s = s.encode('utf-8')
 
 345     def export_data(self):
 
 347         Exports current data contained in the Task as JSON
 
 350         # We need to remove spaces for TW-1504, use custom separators
 
 351         data_tuples = ((key, self._serialize(key, value))
 
 352                        for key, value in six.iteritems(self._data))
 
 354         # Empty string denotes empty serialized value, we do not want
 
 355         # to pass that to TaskWarrior.
 
 356         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
 
 357         data = dict(data_tuples)
 
 358         return json.dumps(data, separators=(',',':'))
 
 361     def _modified_fields(self):
 
 362         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
 
 363         for key in writable_fields:
 
 364             new_value = self._data.get(key)
 
 365             old_value = self._original_data.get(key)
 
 367             # Make sure not to mark data removal as modified field if the
 
 368             # field originally had some empty value
 
 369             if key in self._data and not new_value and not old_value:
 
 372             if new_value != old_value:
 
 377         return bool(list(self._modified_fields))
 
 380 class TaskAnnotation(TaskResource):
 
 381     read_only_fields = ['entry', 'description']
 
 383     def __init__(self, task, data=None):
 
 385         self._load_data(data or dict())
 
 386         super(TaskAnnotation, self).__init__(task.warrior)
 
 389         self.task.remove_annotation(self)
 
 391     def __unicode__(self):
 
 392         return self['description']
 
 394     def __eq__(self, other):
 
 395         # consider 2 annotations equal if they belong to the same task, and
 
 396         # their data dics are the same
 
 397         return self.task == other.task and self._data == other._data
 
 399     __repr__ = __unicode__
 
 402 class Task(TaskResource):
 
 403     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
 405     class DoesNotExist(Exception):
 
 408     class CompletedTask(Exception):
 
 410         Raised when the operation cannot be performed on the completed task.
 
 414     class DeletedTask(Exception):
 
 416         Raised when the operation cannot be performed on the deleted task.
 
 420     class ActiveTask(Exception):
 
 422         Raised when the operation cannot be performed on the active task.
 
 426     class InactiveTask(Exception):
 
 428         Raised when the operation cannot be performed on an inactive task.
 
 432     class NotSaved(Exception):
 
 434         Raised when the operation cannot be performed on the task, because
 
 435         it has not been saved to TaskWarrior yet.
 
 440     def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
 
 442         Creates a Task object, directly from the stdin, by reading one line.
 
 443         If modify=True, two lines are used, first line interpreted as the
 
 444         original state of the Task object, and second line as its new,
 
 445         modified value. This is consistent with the TaskWarrior's hook
 
 448         Object created by this method should not be saved, deleted
 
 449         or refreshed, as t could create a infinite loop. For this
 
 450         reason, TaskWarrior instance is set to None.
 
 452         Input_file argument can be used to specify the input file,
 
 453         but defaults to sys.stdin.
 
 456         # Detect the hook type if not given directly
 
 457         name = os.path.basename(sys.argv[0])
 
 458         modify = name.startswith('on-modify') if modify is None else modify
 
 460         # Create the TaskWarrior instance if none passed
 
 462             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
 
 463             warrior = TaskWarrior(data_location=hook_parent_dir)
 
 465         # TaskWarrior instance is set to None
 
 468         # Load the data from the input
 
 469         task._load_data(json.loads(input_file.readline().strip()))
 
 471         # If this is a on-modify event, we are provided with additional
 
 472         # line of input, which provides updated data
 
 474             task._update_data(json.loads(input_file.readline().strip()),
 
 479     def __init__(self, warrior, **kwargs):
 
 480         super(Task, self).__init__(warrior)
 
 482         # Check that user is not able to set read-only value in __init__
 
 483         for key in kwargs.keys():
 
 484             if key in self.read_only_fields:
 
 485                 raise RuntimeError('Field \'%s\' is read-only' % key)
 
 487         # We serialize the data in kwargs so that users of the library
 
 488         # do not have to pass different data formats via __setitem__ and
 
 489         # __init__ methods, that would be confusing
 
 491         # Rather unfortunate syntax due to python2.6 comaptiblity
 
 492         self._data = dict((key, self._normalize(key, value))
 
 493                           for (key, value) in six.iteritems(kwargs))
 
 494         self._original_data = copy.deepcopy(self._data)
 
 496         # Provide read only access to the original data
 
 497         self.original = ReadOnlyDictView(self._original_data)
 
 499     def __unicode__(self):
 
 500         return self['description']
 
 502     def __eq__(self, other):
 
 503         if self['uuid'] and other['uuid']:
 
 504             # For saved Tasks, just define equality by equality of uuids
 
 505             return self['uuid'] == other['uuid']
 
 507             # If the tasks are not saved, compare the actual instances
 
 508             return id(self) == id(other)
 
 513             # For saved Tasks, just define equality by equality of uuids
 
 514             return self['uuid'].__hash__()
 
 516             # If the tasks are not saved, return hash of instance id
 
 517             return id(self).__hash__()
 
 521         return self['status'] == six.text_type('completed')
 
 525         return self['status'] == six.text_type('deleted')
 
 529         return self['status'] == six.text_type('waiting')
 
 533         return self['status'] == six.text_type('pending')
 
 537         return self['start'] is not None
 
 541         return self['uuid'] is not None or self['id'] is not None
 
 543     def serialize_depends(self, cur_dependencies):
 
 544         # Check that all the tasks are saved
 
 545         for task in (cur_dependencies or set()):
 
 547                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
 
 548                                     'it can be set as dependency.' % task)
 
 550         return super(Task, self).serialize_depends(cur_dependencies)
 
 552     def format_depends(self):
 
 553         # We need to generate added and removed dependencies list,
 
 554         # since Taskwarrior does not accept redefining dependencies.
 
 556         # This cannot be part of serialize_depends, since we need
 
 557         # to keep a list of all depedencies in the _data dictionary,
 
 558         # not just currently added/removed ones
 
 560         old_dependencies = self._original_data.get('depends', set())
 
 562         added = self['depends'] - old_dependencies
 
 563         removed = old_dependencies - self['depends']
 
 565         # Removed dependencies need to be prefixed with '-'
 
 566         return 'depends:' + ','.join(
 
 567                 [t['uuid'] for t in added] +
 
 568                 ['-' + t['uuid'] for t in removed]
 
 571     def format_description(self):
 
 572         # Task version older than 2.4.0 ignores first word of the
 
 573         # task description if description: prefix is used
 
 574         if self.warrior.version < VERSION_2_4_0:
 
 575             return self._data['description']
 
 577             return six.u("description:'{0}'").format(self._data['description'] or '')
 
 581             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
 583         # Refresh the status, and raise exception if the task is deleted
 
 584         self.refresh(only_fields=['status'])
 
 587             raise Task.DeletedTask("Task was already deleted")
 
 589         self.backend.delete_task(self)
 
 591         # Refresh the status again, so that we have updated info stored
 
 592         self.refresh(only_fields=['status', 'start', 'end'])
 
 596             raise Task.NotSaved("Task needs to be saved before it can be started")
 
 598         # Refresh, and raise exception if task is already completed/deleted
 
 599         self.refresh(only_fields=['status'])
 
 602             raise Task.CompletedTask("Cannot start a completed task")
 
 604             raise Task.DeletedTask("Deleted task cannot be started")
 
 606             raise Task.ActiveTask("Task is already active")
 
 608         self.warrior.execute_command([self['uuid'], 'start'])
 
 610         # Refresh the status again, so that we have updated info stored
 
 611         self.refresh(only_fields=['status', 'start'])
 
 615             raise Task.NotSaved("Task needs to be saved before it can be stopped")
 
 617         # Refresh, and raise exception if task is already completed/deleted
 
 618         self.refresh(only_fields=['status'])
 
 621             raise Task.InactiveTask("Cannot stop an inactive task")
 
 623         self.warrior.execute_command([self['uuid'], 'stop'])
 
 625         # Refresh the status again, so that we have updated info stored
 
 626         self.refresh(only_fields=['status', 'start'])
 
 630             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
 632         # Refresh, and raise exception if task is already completed/deleted
 
 633         self.refresh(only_fields=['status'])
 
 636             raise Task.CompletedTask("Cannot complete a completed task")
 
 638             raise Task.DeletedTask("Deleted task cannot be completed")
 
 640         # Older versions of TW do not stop active task at completion
 
 641         if self.warrior.version < VERSION_2_4_0 and self.active:
 
 644         self.warrior.execute_command([self['uuid'], 'done'])
 
 646         # Refresh the status again, so that we have updated info stored
 
 647         self.refresh(only_fields=['status', 'start', 'end'])
 
 650         if self.saved and not self.modified:
 
 653         # All the actual work is done by the backend
 
 654         self.backend.save_task(self)
 
 656     def add_annotation(self, annotation):
 
 658             raise Task.NotSaved("Task needs to be saved to add annotation")
 
 660         args = [self['uuid'], 'annotate', annotation]
 
 661         self.warrior.execute_command(args)
 
 662         self.refresh(only_fields=['annotations'])
 
 664     def remove_annotation(self, annotation):
 
 666             raise Task.NotSaved("Task needs to be saved to remove annotation")
 
 668         if isinstance(annotation, TaskAnnotation):
 
 669             annotation = annotation['description']
 
 670         args = [self['uuid'], 'denotate', annotation]
 
 671         self.warrior.execute_command(args)
 
 672         self.refresh(only_fields=['annotations'])
 
 674     def _get_modified_fields_as_args(self):
 
 677         def add_field(field):
 
 678             # Add the output of format_field method to args list (defaults to
 
 680             serialized_value = self._serialize(field, self._data[field])
 
 682             # Empty values should not be enclosed in quotation marks, see
 
 684             if serialized_value is '':
 
 685                 escaped_serialized_value = ''
 
 687                 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
 
 689             format_default = lambda: six.u("{0}:{1}").format(field,
 
 690                                                       escaped_serialized_value)
 
 692             format_func = getattr(self, 'format_{0}'.format(field),
 
 695             args.append(format_func())
 
 697         # If we're modifying saved task, simply pass on all modified fields
 
 699             for field in self._modified_fields:
 
 701         # For new tasks, pass all fields that make sense
 
 703             for field in self._data.keys():
 
 704                 if field in self.read_only_fields:
 
 710     def refresh(self, only_fields=None, after_save=False):
 
 711         # Raise error when trying to refresh a task that has not been saved
 
 713             raise Task.NotSaved("Task needs to be saved to be refreshed")
 
 715         # We need to use ID as backup for uuid here for the refreshes
 
 716         # of newly saved tasks. Any other place in the code is fine
 
 717         # with using UUID only.
 
 718         args = [self['uuid'] or self['id'], 'export']
 
 719         output = self.warrior.execute_command(args)
 
 722             return len(output) == 1 and output[0].startswith('{')
 
 724         # For older TW versions attempt to uniquely locate the task
 
 725         # using the data we have if it has been just saved.
 
 726         # This can happen when adding a completed task on older TW versions.
 
 727         if (not valid(output) and self.warrior.version < VERSION_2_4_5
 
 730             # Make a copy, removing ID and UUID. It's most likely invalid
 
 731             # (ID 0) if it failed to match a unique task.
 
 732             data = copy.deepcopy(self._data)
 
 734             data.pop('uuid', None)
 
 736             taskfilter = TaskFilter(self.warrior)
 
 737             for key, value in data.items():
 
 738                 taskfilter.add_filter_param(key, value)
 
 740             output = self.warrior.execute_command(['export', '--'] +
 
 741                 taskfilter.get_filter_params())
 
 743         # If more than 1 task has been matched still, raise an exception
 
 744         if not valid(output):
 
 745             raise TaskWarriorException(
 
 746                 "Unique identifiers {0} with description: {1} matches "
 
 747                 "multiple tasks: {2}".format(
 
 748                 self['uuid'] or self['id'], self['description'], output)
 
 751         new_data = json.loads(output[0])
 
 754                 [(k, new_data.get(k)) for k in only_fields])
 
 755             self._update_data(to_update, update_original=True)
 
 757             self._load_data(new_data)
 
 759 class TaskFilter(SerializingObject):
 
 761     A set of parameters to filter the task list with.
 
 764     def __init__(self, warrior, filter_params=None):
 
 765         self.filter_params = filter_params or []
 
 766         super(TaskFilter, self).__init__(warrior)
 
 768     def add_filter(self, filter_str):
 
 769         self.filter_params.append(filter_str)
 
 771     def add_filter_param(self, key, value):
 
 772         key = key.replace('__', '.')
 
 774         # Replace the value with empty string, since that is the
 
 775         # convention in TW for empty values
 
 776         attribute_key = key.split('.')[0]
 
 778         # Since this is user input, we need to normalize before we serialize
 
 779         value = self._normalize(attribute_key, value)
 
 780         value = self._serialize(attribute_key, value)
 
 782         # If we are filtering by uuid:, do not use uuid keyword
 
 785             self.filter_params.insert(0, value)
 
 787             # Surround value with aphostrophes unless it's a empty string
 
 788             value = "'%s'" % value if value else ''
 
 790             # We enforce equality match by using 'is' (or 'none') modifier
 
 791             # Without using this syntax, filter fails due to TW-1479
 
 792             # which is, however, fixed in 2.4.5
 
 793             if self.warrior.version < VERSION_2_4_5:
 
 794                 modifier = '.is' if value else '.none'
 
 795                 key = key + modifier if '.' not in key else key
 
 797             self.filter_params.append(six.u("{0}:{1}").format(key, value))
 
 799     def get_filter_params(self):
 
 800         return [f for f in self.filter_params if f]
 
 803         c = self.__class__(self.warrior)
 
 804         c.filter_params = list(self.filter_params)
 
 808 class TaskQuerySet(object):
 
 810     Represents a lazy lookup for a task objects.
 
 813     def __init__(self, warrior=None, filter_obj=None):
 
 814         self.warrior = warrior
 
 815         self._result_cache = None
 
 816         self.filter_obj = filter_obj or TaskFilter(warrior)
 
 818     def __deepcopy__(self, memo):
 
 820         Deep copy of a QuerySet doesn't populate the cache
 
 822         obj = self.__class__()
 
 823         for k, v in self.__dict__.items():
 
 824             if k in ('_iter', '_result_cache'):
 
 825                 obj.__dict__[k] = None
 
 827                 obj.__dict__[k] = copy.deepcopy(v, memo)
 
 831         data = list(self[:REPR_OUTPUT_SIZE + 1])
 
 832         if len(data) > REPR_OUTPUT_SIZE:
 
 833             data[-1] = "...(remaining elements truncated)..."
 
 837         if self._result_cache is None:
 
 838             self._result_cache = list(self)
 
 839         return len(self._result_cache)
 
 842         if self._result_cache is None:
 
 843             self._result_cache = self._execute()
 
 844         return iter(self._result_cache)
 
 846     def __getitem__(self, k):
 
 847         if self._result_cache is None:
 
 848             self._result_cache = list(self)
 
 849         return self._result_cache.__getitem__(k)
 
 852         if self._result_cache is not None:
 
 853             return bool(self._result_cache)
 
 856         except StopIteration:
 
 860     def __nonzero__(self):
 
 861         return type(self).__bool__(self)
 
 863     def _clone(self, klass=None, **kwargs):
 
 865             klass = self.__class__
 
 866         filter_obj = self.filter_obj.clone()
 
 867         c = klass(warrior=self.warrior, filter_obj=filter_obj)
 
 868         c.__dict__.update(kwargs)
 
 873         Fetch the tasks which match the current filters.
 
 875         return self.warrior.filter_tasks(self.filter_obj)
 
 879         Returns a new TaskQuerySet that is a copy of the current one.
 
 884         return self.filter(status=PENDING)
 
 887         return self.filter(status=COMPLETED)
 
 889     def filter(self, *args, **kwargs):
 
 891         Returns a new TaskQuerySet with the given filters added.
 
 893         clone = self._clone()
 
 895             clone.filter_obj.add_filter(f)
 
 896         for key, value in kwargs.items():
 
 897             clone.filter_obj.add_filter_param(key, value)
 
 900     def get(self, **kwargs):
 
 902         Performs the query and returns a single object matching the given
 
 905         clone = self.filter(**kwargs)
 
 908             return clone._result_cache[0]
 
 910             raise Task.DoesNotExist(
 
 911                 'Task matching query does not exist. '
 
 912                 'Lookup parameters were {0}'.format(kwargs))
 
 914             'get() returned more than one Task -- it returned {0}! '
 
 915             'Lookup parameters were {1}'.format(num, kwargs))