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

61f944a27f1b0030a442ada96a33d0fe2d352a20
[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 tzlocal
11
12 from backends import TaskWarrior, TaskWarriorException
13
14 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
15 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
16 REPR_OUTPUT_SIZE = 10
17 PENDING = 'pending'
18 COMPLETED = 'completed'
19
20 logger = logging.getLogger(__name__)
21 local_zone = tzlocal.get_localzone()
22
23
24 class ReadOnlyDictView(object):
25     """
26     Provides simplified read-only view upon dict object.
27     """
28
29     def __init__(self, viewed_dict):
30         self.viewed_dict = viewed_dict
31
32     def __getitem__(self, key):
33         return copy.deepcopy(self.viewed_dict.__getitem__(key))
34
35     def __contains__(self, k):
36         return self.viewed_dict.__contains__(k)
37
38     def __iter__(self):
39         for value in self.viewed_dict:
40             yield copy.deepcopy(value)
41
42     def __len__(self):
43         return len(self.viewed_dict)
44
45     def get(self, key, default=None):
46         return copy.deepcopy(self.viewed_dict.get(key, default))
47
48     def items(self):
49         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
50
51     def values(self):
52         return [copy.deepcopy(v) for v in self.viewed_dict.values()]
53
54
55 class SerializingObject(object):
56     """
57     Common ancestor for TaskResource & TaskFilter, since they both
58     need to serialize arguments.
59
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
64
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.
71
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
79         to raise ValueError.
80     """
81
82     def __init__(self, warrior):
83         self.warrior = warrior
84
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)
89
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)
94
95     def _normalize(self, key, value):
96         """
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.
100         """
101
102         # None value should not be converted by normalizer
103         if value is None:
104             return None
105
106         normalize_func = getattr(self, 'normalize_{0}'.format(key),
107                                  lambda x: x)
108
109         return normalize_func(value)
110
111     def timestamp_serializer(self, date):
112         if not date:
113             return ''
114
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)
118
119         return date.strftime(DATE_FORMAT)
120
121     def timestamp_deserializer(self, date_str):
122         if not date_str:
123             return None
124
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)
129
130     def serialize_entry(self, value):
131         return self.timestamp_serializer(value)
132
133     def deserialize_entry(self, value):
134         return self.timestamp_deserializer(value)
135
136     def normalize_entry(self, value):
137         return self.datetime_normalizer(value)
138
139     def serialize_modified(self, value):
140         return self.timestamp_serializer(value)
141
142     def deserialize_modified(self, value):
143         return self.timestamp_deserializer(value)
144
145     def normalize_modified(self, value):
146         return self.datetime_normalizer(value)
147
148     def serialize_start(self, value):
149         return self.timestamp_serializer(value)
150
151     def deserialize_start(self, value):
152         return self.timestamp_deserializer(value)
153
154     def normalize_start(self, value):
155         return self.datetime_normalizer(value)
156
157     def serialize_end(self, value):
158         return self.timestamp_serializer(value)
159
160     def deserialize_end(self, value):
161         return self.timestamp_deserializer(value)
162
163     def normalize_end(self, value):
164         return self.datetime_normalizer(value)
165
166     def serialize_due(self, value):
167         return self.timestamp_serializer(value)
168
169     def deserialize_due(self, value):
170         return self.timestamp_deserializer(value)
171
172     def normalize_due(self, value):
173         return self.datetime_normalizer(value)
174
175     def serialize_scheduled(self, value):
176         return self.timestamp_serializer(value)
177
178     def deserialize_scheduled(self, value):
179         return self.timestamp_deserializer(value)
180
181     def normalize_scheduled(self, value):
182         return self.datetime_normalizer(value)
183
184     def serialize_until(self, value):
185         return self.timestamp_serializer(value)
186
187     def deserialize_until(self, value):
188         return self.timestamp_deserializer(value)
189
190     def normalize_until(self, value):
191         return self.datetime_normalizer(value)
192
193     def serialize_wait(self, value):
194         return self.timestamp_serializer(value)
195
196     def deserialize_wait(self, value):
197         return self.timestamp_deserializer(value)
198
199     def normalize_wait(self, value):
200         return self.datetime_normalizer(value)
201
202     def serialize_annotations(self, value):
203         value = value if value is not None else []
204
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 ''
210
211     def deserialize_annotations(self, data):
212         return [TaskAnnotation(self, d) for d in data] if data else []
213
214     def serialize_tags(self, tags):
215         return ','.join(tags) if tags else ''
216
217     def deserialize_tags(self, tags):
218         if isinstance(tags, six.string_types):
219             return tags.split(',') if tags else []
220         return tags or []
221
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)
226
227     def deserialize_depends(self, raw_uuids):
228         raw_uuids = raw_uuids or []  # Convert None to empty list
229
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
234         else:
235             uuids = raw_uuids
236
237         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
238
239     def datetime_normalizer(self, value):
240         """
241         Normalizes date/datetime value (considered to come from user input)
242         to localized datetime value. Following conversions happen:
243
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)
247         """
248
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)
258             else:
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.
261                 localized = value
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
266             args = value.split()
267             result = self.warrior.execute_command(['calc'] + args)
268             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
269             localized = local_zone.localize(naive)
270         else:
271             raise ValueError("Provided value could not be converted to "
272                              "datetime, its type is not supported: {}"
273                              .format(type(value)))
274
275         return localized
276
277     def normalize_uuid(self, value):
278         # Enforce sane UUID
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))
282
283         return value
284
285
286 class TaskResource(SerializingObject):
287     read_only_fields = []
288
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)
295
296     def _update_data(self, data, update_original=False, remove_missing=False):
297         """
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.
301         """
302         self._data.update(dict((key, self._deserialize(key, value))
303                                for key, value in data.items()))
304
305         # In certain situations, we want to treat missing keys as removals
306         if remove_missing:
307             for key in set(self._data.keys()) - set(data.keys()):
308                 self._data[key] = None
309
310         if update_original:
311             self._original_data = copy.deepcopy(self._data)
312
313
314     def __getitem__(self, key):
315         # This is a workaround to make TaskResource non-iterable
316         # over simple index-based iteration
317         try:
318             int(key)
319             raise StopIteration
320         except ValueError:
321             pass
322
323         if key not in self._data:
324             self._data[key] = self._deserialize(key, None)
325
326         return self._data.get(key)
327
328     def __setitem__(self, key, value):
329         if key in self.read_only_fields:
330             raise RuntimeError('Field \'%s\' is read-only' % key)
331
332         # Normalize the user input before saving it
333         value = self._normalize(key, value)
334         self._data[key] = value
335
336     def __str__(self):
337         s = six.text_type(self.__unicode__())
338         if not six.PY3:
339             s = s.encode('utf-8')
340         return s
341
342     def __repr__(self):
343         return str(self)
344
345     def export_data(self):
346         """
347         Exports current data contained in the Task as JSON
348         """
349
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))
353
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=(',',':'))
359
360     @property
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)
366
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:
370                 continue
371
372             if new_value != old_value:
373                 yield key
374
375     @property
376     def modified(self):
377         return bool(list(self._modified_fields))
378
379
380 class TaskAnnotation(TaskResource):
381     read_only_fields = ['entry', 'description']
382
383     def __init__(self, task, data=None):
384         self.task = task
385         self._load_data(data or dict())
386         super(TaskAnnotation, self).__init__(task.warrior)
387
388     def remove(self):
389         self.task.remove_annotation(self)
390
391     def __unicode__(self):
392         return self['description']
393
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
398
399     __repr__ = __unicode__
400
401
402 class Task(TaskResource):
403     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
404
405     class DoesNotExist(Exception):
406         pass
407
408     class CompletedTask(Exception):
409         """
410         Raised when the operation cannot be performed on the completed task.
411         """
412         pass
413
414     class DeletedTask(Exception):
415         """
416         Raised when the operation cannot be performed on the deleted task.
417         """
418         pass
419
420     class ActiveTask(Exception):
421         """
422         Raised when the operation cannot be performed on the active task.
423         """
424         pass
425
426     class InactiveTask(Exception):
427         """
428         Raised when the operation cannot be performed on an inactive task.
429         """
430         pass
431
432     class NotSaved(Exception):
433         """
434         Raised when the operation cannot be performed on the task, because
435         it has not been saved to TaskWarrior yet.
436         """
437         pass
438
439     @classmethod
440     def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
441         """
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
446         system.
447
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.
451
452         Input_file argument can be used to specify the input file,
453         but defaults to sys.stdin.
454         """
455
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
459
460         # Create the TaskWarrior instance if none passed
461         if warrior is None:
462             hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
463             warrior = TaskWarrior(data_location=hook_parent_dir)
464
465         # TaskWarrior instance is set to None
466         task = cls(warrior)
467
468         # Load the data from the input
469         task._load_data(json.loads(input_file.readline().strip()))
470
471         # If this is a on-modify event, we are provided with additional
472         # line of input, which provides updated data
473         if modify:
474             task._update_data(json.loads(input_file.readline().strip()),
475                               remove_missing=True)
476
477         return task
478
479     def __init__(self, warrior, **kwargs):
480         super(Task, self).__init__(warrior)
481
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)
486
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
490
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)
495
496         # Provide read only access to the original data
497         self.original = ReadOnlyDictView(self._original_data)
498
499     def __unicode__(self):
500         return self['description']
501
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']
506         else:
507             # If the tasks are not saved, compare the actual instances
508             return id(self) == id(other)
509
510
511     def __hash__(self):
512         if self['uuid']:
513             # For saved Tasks, just define equality by equality of uuids
514             return self['uuid'].__hash__()
515         else:
516             # If the tasks are not saved, return hash of instance id
517             return id(self).__hash__()
518
519     @property
520     def completed(self):
521         return self['status'] == six.text_type('completed')
522
523     @property
524     def deleted(self):
525         return self['status'] == six.text_type('deleted')
526
527     @property
528     def waiting(self):
529         return self['status'] == six.text_type('waiting')
530
531     @property
532     def pending(self):
533         return self['status'] == six.text_type('pending')
534
535     @property
536     def active(self):
537         return self['start'] is not None
538
539     @property
540     def saved(self):
541         return self['uuid'] is not None or self['id'] is not None
542
543     def serialize_depends(self, cur_dependencies):
544         # Check that all the tasks are saved
545         for task in (cur_dependencies or set()):
546             if not task.saved:
547                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
548                                     'it can be set as dependency.' % task)
549
550         return super(Task, self).serialize_depends(cur_dependencies)
551
552     def format_depends(self):
553         # We need to generate added and removed dependencies list,
554         # since Taskwarrior does not accept redefining dependencies.
555
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
559
560         old_dependencies = self._original_data.get('depends', set())
561
562         added = self['depends'] - old_dependencies
563         removed = old_dependencies - self['depends']
564
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]
569             )
570
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']
576         else:
577             return six.u("description:'{0}'").format(self._data['description'] or '')
578
579     def delete(self):
580         if not self.saved:
581             raise Task.NotSaved("Task needs to be saved before it can be deleted")
582
583         # Refresh the status, and raise exception if the task is deleted
584         self.refresh(only_fields=['status'])
585
586         if self.deleted:
587             raise Task.DeletedTask("Task was already deleted")
588
589         self.backend.delete_task(self)
590
591         # Refresh the status again, so that we have updated info stored
592         self.refresh(only_fields=['status', 'start', 'end'])
593
594     def start(self):
595         if not self.saved:
596             raise Task.NotSaved("Task needs to be saved before it can be started")
597
598         # Refresh, and raise exception if task is already completed/deleted
599         self.refresh(only_fields=['status'])
600
601         if self.completed:
602             raise Task.CompletedTask("Cannot start a completed task")
603         elif self.deleted:
604             raise Task.DeletedTask("Deleted task cannot be started")
605         elif self.active:
606             raise Task.ActiveTask("Task is already active")
607
608         self.warrior.execute_command([self['uuid'], 'start'])
609
610         # Refresh the status again, so that we have updated info stored
611         self.refresh(only_fields=['status', 'start'])
612
613     def stop(self):
614         if not self.saved:
615             raise Task.NotSaved("Task needs to be saved before it can be stopped")
616
617         # Refresh, and raise exception if task is already completed/deleted
618         self.refresh(only_fields=['status'])
619
620         if not self.active:
621             raise Task.InactiveTask("Cannot stop an inactive task")
622
623         self.warrior.execute_command([self['uuid'], 'stop'])
624
625         # Refresh the status again, so that we have updated info stored
626         self.refresh(only_fields=['status', 'start'])
627
628     def done(self):
629         if not self.saved:
630             raise Task.NotSaved("Task needs to be saved before it can be completed")
631
632         # Refresh, and raise exception if task is already completed/deleted
633         self.refresh(only_fields=['status'])
634
635         if self.completed:
636             raise Task.CompletedTask("Cannot complete a completed task")
637         elif self.deleted:
638             raise Task.DeletedTask("Deleted task cannot be completed")
639
640         # Older versions of TW do not stop active task at completion
641         if self.warrior.version < VERSION_2_4_0 and self.active:
642             self.stop()
643
644         self.warrior.execute_command([self['uuid'], 'done'])
645
646         # Refresh the status again, so that we have updated info stored
647         self.refresh(only_fields=['status', 'start', 'end'])
648
649     def save(self):
650         if self.saved and not self.modified:
651             return
652
653         # All the actual work is done by the backend
654         self.backend.save_task(self)
655
656     def add_annotation(self, annotation):
657         if not self.saved:
658             raise Task.NotSaved("Task needs to be saved to add annotation")
659
660         args = [self['uuid'], 'annotate', annotation]
661         self.warrior.execute_command(args)
662         self.refresh(only_fields=['annotations'])
663
664     def remove_annotation(self, annotation):
665         if not self.saved:
666             raise Task.NotSaved("Task needs to be saved to remove annotation")
667
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'])
673
674     def _get_modified_fields_as_args(self):
675         args = []
676
677         def add_field(field):
678             # Add the output of format_field method to args list (defaults to
679             # field:value)
680             serialized_value = self._serialize(field, self._data[field])
681
682             # Empty values should not be enclosed in quotation marks, see
683             # TW-1510
684             if serialized_value is '':
685                 escaped_serialized_value = ''
686             else:
687                 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
688
689             format_default = lambda: six.u("{0}:{1}").format(field,
690                                                       escaped_serialized_value)
691
692             format_func = getattr(self, 'format_{0}'.format(field),
693                                   format_default)
694
695             args.append(format_func())
696
697         # If we're modifying saved task, simply pass on all modified fields
698         if self.saved:
699             for field in self._modified_fields:
700                 add_field(field)
701         # For new tasks, pass all fields that make sense
702         else:
703             for field in self._data.keys():
704                 if field in self.read_only_fields:
705                     continue
706                 add_field(field)
707
708         return args
709
710     def refresh(self, only_fields=None, after_save=False):
711         # Raise error when trying to refresh a task that has not been saved
712         if not self.saved:
713             raise Task.NotSaved("Task needs to be saved to be refreshed")
714
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)
720
721         def valid(output):
722             return len(output) == 1 and output[0].startswith('{')
723
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
728                 and after_save):
729
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)
733             data.pop('id', None)
734             data.pop('uuid', None)
735
736             taskfilter = TaskFilter(self.warrior)
737             for key, value in data.items():
738                 taskfilter.add_filter_param(key, value)
739
740             output = self.warrior.execute_command(['export', '--'] +
741                 taskfilter.get_filter_params())
742
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)
749             )
750
751         new_data = json.loads(output[0])
752         if only_fields:
753             to_update = dict(
754                 [(k, new_data.get(k)) for k in only_fields])
755             self._update_data(to_update, update_original=True)
756         else:
757             self._load_data(new_data)
758
759 class TaskFilter(SerializingObject):
760     """
761     A set of parameters to filter the task list with.
762     """
763
764     def __init__(self, warrior, filter_params=None):
765         self.filter_params = filter_params or []
766         super(TaskFilter, self).__init__(warrior)
767
768     def add_filter(self, filter_str):
769         self.filter_params.append(filter_str)
770
771     def add_filter_param(self, key, value):
772         key = key.replace('__', '.')
773
774         # Replace the value with empty string, since that is the
775         # convention in TW for empty values
776         attribute_key = key.split('.')[0]
777
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)
781
782         # If we are filtering by uuid:, do not use uuid keyword
783         # due to TW-1452 bug
784         if key == 'uuid':
785             self.filter_params.insert(0, value)
786         else:
787             # Surround value with aphostrophes unless it's a empty string
788             value = "'%s'" % value if value else ''
789
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
796
797             self.filter_params.append(six.u("{0}:{1}").format(key, value))
798
799     def get_filter_params(self):
800         return [f for f in self.filter_params if f]
801
802     def clone(self):
803         c = self.__class__(self.warrior)
804         c.filter_params = list(self.filter_params)
805         return c
806
807
808 class TaskQuerySet(object):
809     """
810     Represents a lazy lookup for a task objects.
811     """
812
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)
817
818     def __deepcopy__(self, memo):
819         """
820         Deep copy of a QuerySet doesn't populate the cache
821         """
822         obj = self.__class__()
823         for k, v in self.__dict__.items():
824             if k in ('_iter', '_result_cache'):
825                 obj.__dict__[k] = None
826             else:
827                 obj.__dict__[k] = copy.deepcopy(v, memo)
828         return obj
829
830     def __repr__(self):
831         data = list(self[:REPR_OUTPUT_SIZE + 1])
832         if len(data) > REPR_OUTPUT_SIZE:
833             data[-1] = "...(remaining elements truncated)..."
834         return repr(data)
835
836     def __len__(self):
837         if self._result_cache is None:
838             self._result_cache = list(self)
839         return len(self._result_cache)
840
841     def __iter__(self):
842         if self._result_cache is None:
843             self._result_cache = self._execute()
844         return iter(self._result_cache)
845
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)
850
851     def __bool__(self):
852         if self._result_cache is not None:
853             return bool(self._result_cache)
854         try:
855             next(iter(self))
856         except StopIteration:
857             return False
858         return True
859
860     def __nonzero__(self):
861         return type(self).__bool__(self)
862
863     def _clone(self, klass=None, **kwargs):
864         if klass is None:
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)
869         return c
870
871     def _execute(self):
872         """
873         Fetch the tasks which match the current filters.
874         """
875         return self.warrior.filter_tasks(self.filter_obj)
876
877     def all(self):
878         """
879         Returns a new TaskQuerySet that is a copy of the current one.
880         """
881         return self._clone()
882
883     def pending(self):
884         return self.filter(status=PENDING)
885
886     def completed(self):
887         return self.filter(status=COMPLETED)
888
889     def filter(self, *args, **kwargs):
890         """
891         Returns a new TaskQuerySet with the given filters added.
892         """
893         clone = self._clone()
894         for f in args:
895             clone.filter_obj.add_filter(f)
896         for key, value in kwargs.items():
897             clone.filter_obj.add_filter_param(key, value)
898         return clone
899
900     def get(self, **kwargs):
901         """
902         Performs the query and returns a single object matching the given
903         keyword arguments.
904         """
905         clone = self.filter(**kwargs)
906         num = len(clone)
907         if num == 1:
908             return clone._result_cache[0]
909         if not num:
910             raise Task.DoesNotExist(
911                 'Task matching query does not exist. '
912                 'Lookup parameters were {0}'.format(kwargs))
913         raise ValueError(
914             'get() returned more than one Task -- it returned {0}! '
915             'Lookup parameters were {1}'.format(num, kwargs))