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
14 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
15 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
18 COMPLETED = 'completed'
20 VERSION_2_1_0 = six.u('2.1.0')
21 VERSION_2_2_0 = six.u('2.2.0')
22 VERSION_2_3_0 = six.u('2.3.0')
23 VERSION_2_4_0 = six.u('2.4.0')
24 VERSION_2_4_1 = six.u('2.4.1')
25 VERSION_2_4_2 = six.u('2.4.2')
26 VERSION_2_4_3 = six.u('2.4.3')
27 VERSION_2_4_4 = six.u('2.4.4')
28 VERSION_2_4_5 = six.u('2.4.5')
30 logger = logging.getLogger(__name__)
31 local_zone = tzlocal.get_localzone()
34 class TaskWarriorException(Exception):
38 class ReadOnlyDictView(object):
40 Provides simplified read-only view upon dict object.
43 def __init__(self, viewed_dict):
44 self.viewed_dict = viewed_dict
46 def __getitem__(self, key):
47 return copy.deepcopy(self.viewed_dict.__getitem__(key))
49 def __contains__(self, k):
50 return self.viewed_dict.__contains__(k)
53 for value in self.viewed_dict:
54 yield copy.deepcopy(value)
57 return len(self.viewed_dict)
59 def get(self, key, default=None):
60 return copy.deepcopy(self.viewed_dict.get(key, default))
63 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
66 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
69 class SerializingObject(object):
71 Common ancestor for TaskResource & TaskFilter, since they both
72 need to serialize arguments.
74 Serializing method should hold the following contract:
75 - any empty value (meaning removal of the attribute)
76 is deserialized into a empty string
77 - None denotes a empty value for any attribute
79 Deserializing method should hold the following contract:
80 - None denotes a empty value for any attribute (however,
81 this is here as a safeguard, TaskWarrior currently does
82 not export empty-valued attributes) if the attribute
83 is not iterable (e.g. list or set), in which case
84 a empty iterable should be used.
86 Normalizing methods should hold the following contract:
87 - They are used to validate and normalize the user input.
88 Any attribute value that comes from the user (during Task
89 initialization, assignign values to Task attributes, or
90 filtering by user-provided values of attributes) is first
91 validated and normalized using the normalize_{key} method.
92 - If validation or normalization fails, normalizer is expected
96 def __init__(self, warrior):
97 self.warrior = warrior
99 def _deserialize(self, key, value):
100 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
101 lambda x: x if x != '' else None)
102 return hydrate_func(value)
104 def _serialize(self, key, value):
105 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
106 lambda x: x if x is not None else '')
107 return dehydrate_func(value)
109 def _normalize(self, key, value):
111 Use normalize_<key> methods to normalize user input. Any user
112 input will be normalized at the moment it is used as filter,
113 or entered as a value of Task attribute.
116 # None value should not be converted by normalizer
120 normalize_func = getattr(self, 'normalize_{0}'.format(key),
123 return normalize_func(value)
125 def timestamp_serializer(self, date):
129 # Any serialized timestamp should be localized, we need to
130 # convert to UTC before converting to string (DATE_FORMAT uses UTC)
131 date = date.astimezone(pytz.utc)
133 return date.strftime(DATE_FORMAT)
135 def timestamp_deserializer(self, date_str):
139 # Return timestamp localized in the local zone
140 naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT)
141 localized_timestamp = pytz.utc.localize(naive_timestamp)
142 return localized_timestamp.astimezone(local_zone)
144 def serialize_entry(self, value):
145 return self.timestamp_serializer(value)
147 def deserialize_entry(self, value):
148 return self.timestamp_deserializer(value)
150 def normalize_entry(self, value):
151 return self.datetime_normalizer(value)
153 def serialize_modified(self, value):
154 return self.timestamp_serializer(value)
156 def deserialize_modified(self, value):
157 return self.timestamp_deserializer(value)
159 def normalize_modified(self, value):
160 return self.datetime_normalizer(value)
162 def serialize_start(self, value):
163 return self.timestamp_serializer(value)
165 def deserialize_start(self, value):
166 return self.timestamp_deserializer(value)
168 def normalize_start(self, value):
169 return self.datetime_normalizer(value)
171 def serialize_end(self, value):
172 return self.timestamp_serializer(value)
174 def deserialize_end(self, value):
175 return self.timestamp_deserializer(value)
177 def normalize_end(self, value):
178 return self.datetime_normalizer(value)
180 def serialize_due(self, value):
181 return self.timestamp_serializer(value)
183 def deserialize_due(self, value):
184 return self.timestamp_deserializer(value)
186 def normalize_due(self, value):
187 return self.datetime_normalizer(value)
189 def serialize_scheduled(self, value):
190 return self.timestamp_serializer(value)
192 def deserialize_scheduled(self, value):
193 return self.timestamp_deserializer(value)
195 def normalize_scheduled(self, value):
196 return self.datetime_normalizer(value)
198 def serialize_until(self, value):
199 return self.timestamp_serializer(value)
201 def deserialize_until(self, value):
202 return self.timestamp_deserializer(value)
204 def normalize_until(self, value):
205 return self.datetime_normalizer(value)
207 def serialize_wait(self, value):
208 return self.timestamp_serializer(value)
210 def deserialize_wait(self, value):
211 return self.timestamp_deserializer(value)
213 def normalize_wait(self, value):
214 return self.datetime_normalizer(value)
216 def serialize_annotations(self, value):
217 value = value if value is not None else []
219 # This may seem weird, but it's correct, we want to export
220 # a list of dicts as serialized value
221 serialized_annotations = [json.loads(annotation.export_data())
222 for annotation in value]
223 return serialized_annotations if serialized_annotations else ''
225 def deserialize_annotations(self, data):
226 return [TaskAnnotation(self, d) for d in data] if data else []
228 def serialize_tags(self, tags):
229 return ','.join(tags) if tags else ''
231 def deserialize_tags(self, tags):
232 if isinstance(tags, six.string_types):
233 return tags.split(',') if tags else []
236 def serialize_depends(self, value):
237 # Return the list of uuids
238 value = value if value is not None else set()
239 return ','.join(task['uuid'] for task in value)
241 def deserialize_depends(self, raw_uuids):
242 raw_uuids = raw_uuids or [] # Convert None to empty list
244 # TW 2.4.4 encodes list of dependencies as a single string
245 if type(raw_uuids) is not list:
246 uuids = raw_uuids.split(',')
247 # TW 2.4.5 and later exports them as a list, no conversion needed
251 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
253 def datetime_normalizer(self, value):
255 Normalizes date/datetime value (considered to come from user input)
256 to localized datetime value. Following conversions happen:
258 naive date -> localized datetime with the same date, and time=midnight
259 naive datetime -> localized datetime with the same value
260 localized datetime -> localized datetime (no conversion)
263 if (isinstance(value, datetime.date)
264 and not isinstance(value, datetime.datetime)):
265 # Convert to local midnight
266 value_full = datetime.datetime.combine(value, datetime.time.min)
267 localized = local_zone.localize(value_full)
268 elif isinstance(value, datetime.datetime):
269 if value.tzinfo is None:
270 # Convert to localized datetime object
271 localized = local_zone.localize(value)
273 # If the value is already localized, there is no need to change
274 # time zone at this point. Also None is a valid value too.
276 elif (isinstance(value, six.string_types)
277 and self.warrior.version >= VERSION_2_4_0):
278 # For strings, use 'task calc' to evaluate the string to datetime
279 # available since TW 2.4.0
281 result = self.warrior.execute_command(['calc'] + args)
282 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
283 localized = local_zone.localize(naive)
285 raise ValueError("Provided value could not be converted to "
286 "datetime, its type is not supported: {}"
287 .format(type(value)))
291 def normalize_uuid(self, value):
293 if not isinstance(value, six.string_types) or value == '':
294 raise ValueError("UUID must be a valid non-empty string, "
295 "not: {}".format(value))
300 class TaskResource(SerializingObject):
301 read_only_fields = []
303 def _load_data(self, data):
304 self._data = dict((key, self._deserialize(key, value))
305 for key, value in data.items())
306 # We need to use a copy for original data, so that changes
307 # are not propagated.
308 self._original_data = copy.deepcopy(self._data)
310 def _update_data(self, data, update_original=False, remove_missing=False):
312 Low level update of the internal _data dict. Data which are coming as
313 updates should already be serialized. If update_original is True, the
314 original_data dict is updated as well.
316 self._data.update(dict((key, self._deserialize(key, value))
317 for key, value in data.items()))
319 # In certain situations, we want to treat missing keys as removals
321 for key in set(self._data.keys()) - set(data.keys()):
322 self._data[key] = None
325 self._original_data = copy.deepcopy(self._data)
328 def __getitem__(self, key):
329 # This is a workaround to make TaskResource non-iterable
330 # over simple index-based iteration
337 if key not in self._data:
338 self._data[key] = self._deserialize(key, None)
340 return self._data.get(key)
342 def __setitem__(self, key, value):
343 if key in self.read_only_fields:
344 raise RuntimeError('Field \'%s\' is read-only' % key)
346 # Normalize the user input before saving it
347 value = self._normalize(key, value)
348 self._data[key] = value
351 s = six.text_type(self.__unicode__())
353 s = s.encode('utf-8')
359 def export_data(self):
361 Exports current data contained in the Task as JSON
364 # We need to remove spaces for TW-1504, use custom separators
365 data_tuples = ((key, self._serialize(key, value))
366 for key, value in six.iteritems(self._data))
368 # Empty string denotes empty serialized value, we do not want
369 # to pass that to TaskWarrior.
370 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
371 data = dict(data_tuples)
372 return json.dumps(data, separators=(',',':'))
375 def _modified_fields(self):
376 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
377 for key in writable_fields:
378 new_value = self._data.get(key)
379 old_value = self._original_data.get(key)
381 # Make sure not to mark data removal as modified field if the
382 # field originally had some empty value
383 if key in self._data and not new_value and not old_value:
386 if new_value != old_value:
391 return bool(list(self._modified_fields))
394 class TaskAnnotation(TaskResource):
395 read_only_fields = ['entry', 'description']
397 def __init__(self, task, data=None):
399 self._load_data(data or dict())
400 super(TaskAnnotation, self).__init__(task.warrior)
403 self.task.remove_annotation(self)
405 def __unicode__(self):
406 return self['description']
408 def __eq__(self, other):
409 # consider 2 annotations equal if they belong to the same task, and
410 # their data dics are the same
411 return self.task == other.task and self._data == other._data
413 __repr__ = __unicode__
416 class Task(TaskResource):
417 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
419 class DoesNotExist(Exception):
422 class CompletedTask(Exception):
424 Raised when the operation cannot be performed on the completed task.
428 class DeletedTask(Exception):
430 Raised when the operation cannot be performed on the deleted task.
434 class ActiveTask(Exception):
436 Raised when the operation cannot be performed on the active task.
440 class InactiveTask(Exception):
442 Raised when the operation cannot be performed on an inactive task.
446 class NotSaved(Exception):
448 Raised when the operation cannot be performed on the task, because
449 it has not been saved to TaskWarrior yet.
454 def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
456 Creates a Task object, directly from the stdin, by reading one line.
457 If modify=True, two lines are used, first line interpreted as the
458 original state of the Task object, and second line as its new,
459 modified value. This is consistent with the TaskWarrior's hook
462 Object created by this method should not be saved, deleted
463 or refreshed, as t could create a infinite loop. For this
464 reason, TaskWarrior instance is set to None.
466 Input_file argument can be used to specify the input file,
467 but defaults to sys.stdin.
470 # Detect the hook type if not given directly
471 name = os.path.basename(sys.argv[0])
472 modify = name.startswith('on-modify') if modify is None else modify
474 # Create the TaskWarrior instance if none passed
476 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
477 warrior = TaskWarrior(data_location=hook_parent_dir)
479 # TaskWarrior instance is set to None
482 # Load the data from the input
483 task._load_data(json.loads(input_file.readline().strip()))
485 # If this is a on-modify event, we are provided with additional
486 # line of input, which provides updated data
488 task._update_data(json.loads(input_file.readline().strip()),
493 def __init__(self, warrior, **kwargs):
494 super(Task, self).__init__(warrior)
496 # Check that user is not able to set read-only value in __init__
497 for key in kwargs.keys():
498 if key in self.read_only_fields:
499 raise RuntimeError('Field \'%s\' is read-only' % key)
501 # We serialize the data in kwargs so that users of the library
502 # do not have to pass different data formats via __setitem__ and
503 # __init__ methods, that would be confusing
505 # Rather unfortunate syntax due to python2.6 comaptiblity
506 self._data = dict((key, self._normalize(key, value))
507 for (key, value) in six.iteritems(kwargs))
508 self._original_data = copy.deepcopy(self._data)
510 # Provide read only access to the original data
511 self.original = ReadOnlyDictView(self._original_data)
513 def __unicode__(self):
514 return self['description']
516 def __eq__(self, other):
517 if self['uuid'] and other['uuid']:
518 # For saved Tasks, just define equality by equality of uuids
519 return self['uuid'] == other['uuid']
521 # If the tasks are not saved, compare the actual instances
522 return id(self) == id(other)
527 # For saved Tasks, just define equality by equality of uuids
528 return self['uuid'].__hash__()
530 # If the tasks are not saved, return hash of instance id
531 return id(self).__hash__()
535 return self['status'] == six.text_type('completed')
539 return self['status'] == six.text_type('deleted')
543 return self['status'] == six.text_type('waiting')
547 return self['status'] == six.text_type('pending')
551 return self['start'] is not None
555 return self['uuid'] is not None or self['id'] is not None
557 def serialize_depends(self, cur_dependencies):
558 # Check that all the tasks are saved
559 for task in (cur_dependencies or set()):
561 raise Task.NotSaved('Task \'%s\' needs to be saved before '
562 'it can be set as dependency.' % task)
564 return super(Task, self).serialize_depends(cur_dependencies)
566 def format_depends(self):
567 # We need to generate added and removed dependencies list,
568 # since Taskwarrior does not accept redefining dependencies.
570 # This cannot be part of serialize_depends, since we need
571 # to keep a list of all depedencies in the _data dictionary,
572 # not just currently added/removed ones
574 old_dependencies = self._original_data.get('depends', set())
576 added = self['depends'] - old_dependencies
577 removed = old_dependencies - self['depends']
579 # Removed dependencies need to be prefixed with '-'
580 return 'depends:' + ','.join(
581 [t['uuid'] for t in added] +
582 ['-' + t['uuid'] for t in removed]
585 def format_description(self):
586 # Task version older than 2.4.0 ignores first word of the
587 # task description if description: prefix is used
588 if self.warrior.version < VERSION_2_4_0:
589 return self._data['description']
591 return six.u("description:'{0}'").format(self._data['description'] or '')
595 raise Task.NotSaved("Task needs to be saved before it can be deleted")
597 # Refresh the status, and raise exception if the task is deleted
598 self.refresh(only_fields=['status'])
601 raise Task.DeletedTask("Task was already deleted")
603 self.warrior.execute_command([self['uuid'], 'delete'])
605 # Refresh the status again, so that we have updated info stored
606 self.refresh(only_fields=['status', 'start', 'end'])
610 raise Task.NotSaved("Task needs to be saved before it can be started")
612 # Refresh, and raise exception if task is already completed/deleted
613 self.refresh(only_fields=['status'])
616 raise Task.CompletedTask("Cannot start a completed task")
618 raise Task.DeletedTask("Deleted task cannot be started")
620 raise Task.ActiveTask("Task is already active")
622 self.warrior.execute_command([self['uuid'], 'start'])
624 # Refresh the status again, so that we have updated info stored
625 self.refresh(only_fields=['status', 'start'])
629 raise Task.NotSaved("Task needs to be saved before it can be stopped")
631 # Refresh, and raise exception if task is already completed/deleted
632 self.refresh(only_fields=['status'])
635 raise Task.InactiveTask("Cannot stop an inactive task")
637 self.warrior.execute_command([self['uuid'], 'stop'])
639 # Refresh the status again, so that we have updated info stored
640 self.refresh(only_fields=['status', 'start'])
644 raise Task.NotSaved("Task needs to be saved before it can be completed")
646 # Refresh, and raise exception if task is already completed/deleted
647 self.refresh(only_fields=['status'])
650 raise Task.CompletedTask("Cannot complete a completed task")
652 raise Task.DeletedTask("Deleted task cannot be completed")
654 # Older versions of TW do not stop active task at completion
655 if self.warrior.version < VERSION_2_4_0 and self.active:
658 self.warrior.execute_command([self['uuid'], 'done'])
660 # Refresh the status again, so that we have updated info stored
661 self.refresh(only_fields=['status', 'start', 'end'])
664 if self.saved and not self.modified:
667 args = [self['uuid'], 'modify'] if self.saved else ['add']
668 args.extend(self._get_modified_fields_as_args())
669 output = self.warrior.execute_command(args)
671 # Parse out the new ID, if the task is being added for the first time
673 id_lines = [l for l in output if l.startswith('Created task ')]
675 # Complain loudly if it seems that more tasks were created
677 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
678 raise TaskWarriorException("Unexpected output when creating "
679 "task: %s" % '\n'.join(id_lines))
681 # Circumvent the ID storage, since ID is considered read-only
682 identifier = id_lines[0].split(' ')[2].rstrip('.')
684 # Identifier can be either ID or UUID for completed tasks
686 self._data['id'] = int(identifier)
688 self._data['uuid'] = identifier
690 # Refreshing is very important here, as not only modification time
691 # is updated, but arbitrary attribute may have changed due hooks
692 # altering the data before saving
693 self.refresh(after_save=True)
695 def add_annotation(self, annotation):
697 raise Task.NotSaved("Task needs to be saved to add annotation")
699 args = [self['uuid'], 'annotate', annotation]
700 self.warrior.execute_command(args)
701 self.refresh(only_fields=['annotations'])
703 def remove_annotation(self, annotation):
705 raise Task.NotSaved("Task needs to be saved to remove annotation")
707 if isinstance(annotation, TaskAnnotation):
708 annotation = annotation['description']
709 args = [self['uuid'], 'denotate', annotation]
710 self.warrior.execute_command(args)
711 self.refresh(only_fields=['annotations'])
713 def _get_modified_fields_as_args(self):
716 def add_field(field):
717 # Add the output of format_field method to args list (defaults to
719 serialized_value = self._serialize(field, self._data[field])
721 # Empty values should not be enclosed in quotation marks, see
723 if serialized_value is '':
724 escaped_serialized_value = ''
726 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
728 format_default = lambda: six.u("{0}:{1}").format(field,
729 escaped_serialized_value)
731 format_func = getattr(self, 'format_{0}'.format(field),
734 args.append(format_func())
736 # If we're modifying saved task, simply pass on all modified fields
738 for field in self._modified_fields:
740 # For new tasks, pass all fields that make sense
742 for field in self._data.keys():
743 if field in self.read_only_fields:
749 def refresh(self, only_fields=None, after_save=False):
750 # Raise error when trying to refresh a task that has not been saved
752 raise Task.NotSaved("Task needs to be saved to be refreshed")
754 # We need to use ID as backup for uuid here for the refreshes
755 # of newly saved tasks. Any other place in the code is fine
756 # with using UUID only.
757 args = [self['uuid'] or self['id'], 'export']
758 output = self.warrior.execute_command(args)
761 return len(output) == 1 and output[0].startswith('{')
763 # For older TW versions attempt to uniquely locate the task
764 # using the data we have if it has been just saved.
765 # This can happen when adding a completed task on older TW versions.
766 if (not valid(output) and self.warrior.version < VERSION_2_4_5
769 # Make a copy, removing ID and UUID. It's most likely invalid
770 # (ID 0) if it failed to match a unique task.
771 data = copy.deepcopy(self._data)
773 data.pop('uuid', None)
775 taskfilter = TaskFilter(self.warrior)
776 for key, value in data.items():
777 taskfilter.add_filter_param(key, value)
779 output = self.warrior.execute_command(['export', '--'] +
780 taskfilter.get_filter_params())
782 # If more than 1 task has been matched still, raise an exception
783 if not valid(output):
784 raise TaskWarriorException(
785 "Unique identifiers {0} with description: {1} matches "
786 "multiple tasks: {2}".format(
787 self['uuid'] or self['id'], self['description'], output)
790 new_data = json.loads(output[0])
793 [(k, new_data.get(k)) for k in only_fields])
794 self._update_data(to_update, update_original=True)
796 self._load_data(new_data)
798 class TaskFilter(SerializingObject):
800 A set of parameters to filter the task list with.
803 def __init__(self, warrior, filter_params=None):
804 self.filter_params = filter_params or []
805 super(TaskFilter, self).__init__(warrior)
807 def add_filter(self, filter_str):
808 self.filter_params.append(filter_str)
810 def add_filter_param(self, key, value):
811 key = key.replace('__', '.')
813 # Replace the value with empty string, since that is the
814 # convention in TW for empty values
815 attribute_key = key.split('.')[0]
817 # Since this is user input, we need to normalize before we serialize
818 value = self._normalize(attribute_key, value)
819 value = self._serialize(attribute_key, value)
821 # If we are filtering by uuid:, do not use uuid keyword
824 self.filter_params.insert(0, value)
826 # Surround value with aphostrophes unless it's a empty string
827 value = "'%s'" % value if value else ''
829 # We enforce equality match by using 'is' (or 'none') modifier
830 # Without using this syntax, filter fails due to TW-1479
831 # which is, however, fixed in 2.4.5
832 if self.warrior.version < VERSION_2_4_5:
833 modifier = '.is' if value else '.none'
834 key = key + modifier if '.' not in key else key
836 self.filter_params.append(six.u("{0}:{1}").format(key, value))
838 def get_filter_params(self):
839 return [f for f in self.filter_params if f]
842 c = self.__class__(self.warrior)
843 c.filter_params = list(self.filter_params)
847 class TaskQuerySet(object):
849 Represents a lazy lookup for a task objects.
852 def __init__(self, warrior=None, filter_obj=None):
853 self.warrior = warrior
854 self._result_cache = None
855 self.filter_obj = filter_obj or TaskFilter(warrior)
857 def __deepcopy__(self, memo):
859 Deep copy of a QuerySet doesn't populate the cache
861 obj = self.__class__()
862 for k, v in self.__dict__.items():
863 if k in ('_iter', '_result_cache'):
864 obj.__dict__[k] = None
866 obj.__dict__[k] = copy.deepcopy(v, memo)
870 data = list(self[:REPR_OUTPUT_SIZE + 1])
871 if len(data) > REPR_OUTPUT_SIZE:
872 data[-1] = "...(remaining elements truncated)..."
876 if self._result_cache is None:
877 self._result_cache = list(self)
878 return len(self._result_cache)
881 if self._result_cache is None:
882 self._result_cache = self._execute()
883 return iter(self._result_cache)
885 def __getitem__(self, k):
886 if self._result_cache is None:
887 self._result_cache = list(self)
888 return self._result_cache.__getitem__(k)
891 if self._result_cache is not None:
892 return bool(self._result_cache)
895 except StopIteration:
899 def __nonzero__(self):
900 return type(self).__bool__(self)
902 def _clone(self, klass=None, **kwargs):
904 klass = self.__class__
905 filter_obj = self.filter_obj.clone()
906 c = klass(warrior=self.warrior, filter_obj=filter_obj)
907 c.__dict__.update(kwargs)
912 Fetch the tasks which match the current filters.
914 return self.warrior.filter_tasks(self.filter_obj)
918 Returns a new TaskQuerySet that is a copy of the current one.
923 return self.filter(status=PENDING)
926 return self.filter(status=COMPLETED)
928 def filter(self, *args, **kwargs):
930 Returns a new TaskQuerySet with the given filters added.
932 clone = self._clone()
934 clone.filter_obj.add_filter(f)
935 for key, value in kwargs.items():
936 clone.filter_obj.add_filter_param(key, value)
939 def get(self, **kwargs):
941 Performs the query and returns a single object matching the given
944 clone = self.filter(**kwargs)
947 return clone._result_cache[0]
949 raise Task.DoesNotExist(
950 'Task matching query does not exist. '
951 'Lookup parameters were {0}'.format(kwargs))
953 'get() returned more than one Task -- it returned {0}! '
954 'Lookup parameters were {1}'.format(num, kwargs))
957 class TaskWarrior(object):
958 def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
959 self.taskrc_location = os.path.expanduser(taskrc_location)
961 # If taskrc does not exist, pass / to use defaults and avoid creating
962 # dummy .taskrc file by TaskWarrior
963 if not os.path.exists(self.taskrc_location):
964 self.taskrc_location = '/'
966 self.version = self._get_version()
968 'confirmation': 'no',
969 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
970 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
972 # Defaults to on since 2.4.5, we expect off during parsing
975 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
976 # arbitrary big number which is likely to be large enough
977 'bulk': 0 if self.version >= VERSION_2_4_3 else 100000,
980 # Set data.location override if passed via kwarg
981 if data_location is not None:
982 data_location = os.path.expanduser(data_location)
983 if create and not os.path.exists(data_location):
984 os.makedirs(data_location)
985 self.config['data.location'] = data_location
987 self.tasks = TaskQuerySet(self)
989 def _get_command_args(self, args, config_override=None):
990 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
991 config = self.config.copy()
992 config.update(config_override or dict())
993 for item in config.items():
994 command_args.append('rc.{0}={1}'.format(*item))
995 command_args.extend(map(six.text_type, args))
998 def _get_version(self):
999 p = subprocess.Popen(
1000 ['task', '--version'],
1001 stdout=subprocess.PIPE,
1002 stderr=subprocess.PIPE)
1003 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
1004 return stdout.strip('\n')
1006 def get_config(self):
1007 raw_output = self.execute_command(
1009 config_override={'verbose': 'nothing'}
1013 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
1015 for line in raw_output:
1016 match = config_regex.match(line)
1018 config[match.group('key')] = match.group('value').strip()
1022 def execute_command(self, args, config_override=None, allow_failure=True,
1024 command_args = self._get_command_args(
1025 args, config_override=config_override)
1026 logger.debug(' '.join(command_args))
1027 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
1028 stderr=subprocess.PIPE)
1029 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
1030 if p.returncode and allow_failure:
1032 error_msg = stderr.strip()
1034 error_msg = stdout.strip()
1035 raise TaskWarriorException(error_msg)
1037 # Return all whole triplet only if explicitly asked for
1039 return stdout.rstrip().split('\n')
1041 return (stdout.rstrip().split('\n'),
1042 stderr.rstrip().split('\n'),
1045 def enforce_recurrence(self):
1046 # Run arbitrary report command which will trigger generation
1047 # of recurrent tasks.
1049 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
1050 if self.version < VERSION_2_4_2:
1051 self.execute_command(['next'], allow_failure=False)
1053 def filter_tasks(self, filter_obj):
1054 self.enforce_recurrence()
1055 args = ['export', '--'] + filter_obj.get_filter_params()
1057 for line in self.execute_command(args):
1059 data = line.strip(',')
1061 filtered_task = Task(self)
1062 filtered_task._load_data(json.loads(data))
1063 tasks.append(filtered_task)
1065 raise TaskWarriorException('Invalid JSON: %s' % data)
1068 def merge_with(self, path, push=False):
1069 path = path.rstrip('/') + '/'
1070 self.execute_command(['merge', path], config_override={
1071 'merge.autopush': 'yes' if push else 'no',
1075 self.execute_command(['undo'])