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
13 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
16 COMPLETED = 'completed'
18 VERSION_2_1_0 = six.u('2.1.0')
19 VERSION_2_2_0 = six.u('2.2.0')
20 VERSION_2_3_0 = six.u('2.3.0')
21 VERSION_2_4_0 = six.u('2.4.0')
23 logger = logging.getLogger(__name__)
24 local_zone = tzlocal.get_localzone()
27 class TaskWarriorException(Exception):
31 class ReadOnlyDictView(object):
33 Provides simplified read-only view upon dict object.
36 def __init__(self, viewed_dict):
37 self.viewed_dict = viewed_dict
39 def __getitem__(self, key):
40 return copy.deepcopy(self.viewed_dict.__getitem__(key))
42 def __contains__(self, k):
43 return self.viewed_dict.__contains__(k)
46 for value in self.viewed_dict:
47 yield copy.deepcopy(value)
50 return len(self.viewed_dict)
52 def get(self, key, default=None):
53 return copy.deepcopy(self.viewed_dict.get(key, default))
55 def has_key(self, key):
56 return self.viewed_dict.has_key(key)
59 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
62 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
65 class SerializingObject(object):
67 Common ancestor for TaskResource & TaskFilter, since they both
68 need to serialize arguments.
70 Serializing method should hold the following contract:
71 - any empty value (meaning removal of the attribute)
72 is deserialized into a empty string
73 - None denotes a empty value for any attribute
75 Deserializing method should hold the following contract:
76 - None denotes a empty value for any attribute (however,
77 this is here as a safeguard, TaskWarrior currently does
78 not export empty-valued attributes) if the attribute
79 is not iterable (e.g. list or set), in which case
80 a empty iterable should be used.
83 def _deserialize(self, key, value):
84 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
85 lambda x: x if x != '' else None)
86 return hydrate_func(value)
88 def _serialize(self, key, value):
89 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
90 lambda x: x if x is not None else '')
91 return dehydrate_func(value)
93 def _normalize(self, key, value):
95 Use normalize_<key> methods to normalize user input. Any user
96 input will be normalized at the moment it is used as filter,
97 or entered as a value of Task attribute.
100 normalize_func = getattr(self, 'normalize_{0}'.format(key),
103 return normalize_func(value)
105 def timestamp_serializer(self, date):
109 # Any serialized timestamp should be localized, we need to
110 # convert to UTC before converting to string (DATE_FORMAT uses UTC)
111 date = date.astimezone(pytz.utc)
113 return date.strftime(DATE_FORMAT)
115 def timestamp_deserializer(self, date_str):
119 # Return timestamp localized in the local zone
120 naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT)
121 localized_timestamp = pytz.utc.localize(naive_timestamp)
122 return localized_timestamp.astimezone(local_zone)
124 def serialize_entry(self, value):
125 return self.timestamp_serializer(value)
127 def deserialize_entry(self, value):
128 return self.timestamp_deserializer(value)
130 def normalize_entry(self, value):
131 return self.datetime_normalizer(value)
133 def serialize_modified(self, value):
134 return self.timestamp_serializer(value)
136 def deserialize_modified(self, value):
137 return self.timestamp_deserializer(value)
139 def normalize_modified(self, value):
140 return self.datetime_normalizer(value)
142 def serialize_due(self, value):
143 return self.timestamp_serializer(value)
145 def deserialize_due(self, value):
146 return self.timestamp_deserializer(value)
148 def normalize_due(self, value):
149 return self.datetime_normalizer(value)
151 def serialize_scheduled(self, value):
152 return self.timestamp_serializer(value)
154 def deserialize_scheduled(self, value):
155 return self.timestamp_deserializer(value)
157 def normalize_scheduled(self, value):
158 return self.datetime_normalizer(value)
160 def serialize_until(self, value):
161 return self.timestamp_serializer(value)
163 def deserialize_until(self, value):
164 return self.timestamp_deserializer(value)
166 def normalize_until(self, value):
167 return self.datetime_normalizer(value)
169 def serialize_wait(self, value):
170 return self.timestamp_serializer(value)
172 def deserialize_wait(self, value):
173 return self.timestamp_deserializer(value)
175 def normalize_wait(self, value):
176 return self.datetime_normalizer(value)
178 def serialize_annotations(self, value):
179 value = value if value is not None else []
181 # This may seem weird, but it's correct, we want to export
182 # a list of dicts as serialized value
183 serialized_annotations = [json.loads(annotation.export_data())
184 for annotation in value]
185 return serialized_annotations if serialized_annotations else ''
187 def deserialize_annotations(self, data):
188 return [TaskAnnotation(self, d) for d in data] if data else []
190 def serialize_tags(self, tags):
191 return ','.join(tags) if tags else ''
193 def deserialize_tags(self, tags):
194 if isinstance(tags, six.string_types):
195 return tags.split(',') if tags else []
198 def serialize_depends(self, value):
199 # Return the list of uuids
200 value = value if value is not None else set()
201 return ','.join(task['uuid'] for task in value)
203 def deserialize_depends(self, raw_uuids):
204 raw_uuids = raw_uuids or '' # Convert None to empty string
205 uuids = raw_uuids.split(',')
206 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
208 def datetime_normalizer(self, value):
210 Normalizes date/datetime value (considered to come from user input)
211 to localized datetime value. Following conversions happen:
213 naive date -> localized datetime with the same date, and time=midnight
214 naive datetime -> localized datetime with the same value
215 localized datetime -> localized datetime (no conversion)
218 if (isinstance(value, datetime.date)
219 and not isinstance(value, datetime.datetime)):
220 # Convert to local midnight
221 value_full = datetime.datetime.combine(value, datetime.time.min)
222 localized = local_zone.localize(value_full)
223 elif isinstance(value, datetime.datetime) and value.tzinfo is None:
224 # Convert to localized datetime object
225 localized = local_zone.localize(value)
227 # If the value is already localized, there is no need to change
228 # time zone at this point. Also None is a valid value too.
233 def normalize_uuid(self, value):
235 if not isinstance(value, six.text_type) or value == '':
236 raise ValueError("UUID must be a valid non-empty string.")
241 class TaskResource(SerializingObject):
242 read_only_fields = []
244 def _load_data(self, data):
245 self._data = dict((key, self._deserialize(key, value))
246 for key, value in data.items())
247 # We need to use a copy for original data, so that changes
248 # are not propagated.
249 self._original_data = copy.deepcopy(self._data)
251 def _update_data(self, data, update_original=False):
253 Low level update of the internal _data dict. Data which are coming as
254 updates should already be serialized. If update_original is True, the
255 original_data dict is updated as well.
257 self._data.update(dict((key, self._deserialize(key, value))
258 for key, value in data.items()))
261 self._original_data = copy.deepcopy(self._data)
264 def __getitem__(self, key):
265 # This is a workaround to make TaskResource non-iterable
266 # over simple index-based iteration
273 if key not in self._data:
274 self._data[key] = self._deserialize(key, None)
276 return self._data.get(key)
278 def __setitem__(self, key, value):
279 if key in self.read_only_fields:
280 raise RuntimeError('Field \'%s\' is read-only' % key)
282 # Normalize the user input before saving it
283 value = self._normalize(key, value)
284 self._data[key] = value
287 s = six.text_type(self.__unicode__())
289 s = s.encode('utf-8')
295 def export_data(self):
297 Exports current data contained in the Task as JSON
300 # We need to remove spaces for TW-1504, use custom separators
301 data_tuples = ((key, self._serialize(key, value))
302 for key, value in six.iteritems(self._data))
304 # Empty string denotes empty serialized value, we do not want
305 # to pass that to TaskWarrior.
306 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
307 data = dict(data_tuples)
308 return json.dumps(data, separators=(',',':'))
311 def _modified_fields(self):
312 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
313 for key in writable_fields:
314 new_value = self._data.get(key)
315 old_value = self._original_data.get(key)
317 # Make sure not to mark data removal as modified field if the
318 # field originally had some empty value
319 if key in self._data and not new_value and not old_value:
322 if new_value != old_value:
327 return bool(list(self._modified_fields))
330 class TaskAnnotation(TaskResource):
331 read_only_fields = ['entry', 'description']
333 def __init__(self, task, data={}):
335 self._load_data(data)
338 self.task.remove_annotation(self)
340 def __unicode__(self):
341 return self['description']
343 def __eq__(self, other):
344 # consider 2 annotations equal if they belong to the same task, and
345 # their data dics are the same
346 return self.task == other.task and self._data == other._data
348 __repr__ = __unicode__
351 class Task(TaskResource):
352 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
354 class DoesNotExist(Exception):
357 class CompletedTask(Exception):
359 Raised when the operation cannot be performed on the completed task.
363 class DeletedTask(Exception):
365 Raised when the operation cannot be performed on the deleted task.
369 class NotSaved(Exception):
371 Raised when the operation cannot be performed on the task, because
372 it has not been saved to TaskWarrior yet.
377 def from_input(cls, input_file=sys.stdin, modify=None):
379 Creates a Task object, directly from the stdin, by reading one line.
380 If modify=True, two lines are used, first line interpreted as the
381 original state of the Task object, and second line as its new,
382 modified value. This is consistent with the TaskWarrior's hook
385 Object created by this method should not be saved, deleted
386 or refreshed, as t could create a infinite loop. For this
387 reason, TaskWarrior instance is set to None.
389 Input_file argument can be used to specify the input file,
390 but defaults to sys.stdin.
393 # TaskWarrior instance is set to None
396 # Detect the hook type if not given directly
397 name = os.path.basename(sys.argv[0])
398 modify = name.startswith('on-modify') if modify is None else modify
400 # Load the data from the input
401 task._load_data(json.loads(input_file.readline().strip()))
403 # If this is a on-modify event, we are provided with additional
404 # line of input, which provides updated data
406 task._update_data(json.loads(input_file.readline().strip()))
410 def __init__(self, warrior, **kwargs):
411 self.warrior = warrior
413 # Check that user is not able to set read-only value in __init__
414 for key in kwargs.keys():
415 if key in self.read_only_fields:
416 raise RuntimeError('Field \'%s\' is read-only' % key)
418 # We serialize the data in kwargs so that users of the library
419 # do not have to pass different data formats via __setitem__ and
420 # __init__ methods, that would be confusing
422 # Rather unfortunate syntax due to python2.6 comaptiblity
423 self._data = dict((key, self._normalize(key, value))
424 for (key, value) in six.iteritems(kwargs))
425 self._original_data = copy.deepcopy(self._data)
427 def __unicode__(self):
428 return self['description']
430 def __eq__(self, other):
431 if self['uuid'] and other['uuid']:
432 # For saved Tasks, just define equality by equality of uuids
433 return self['uuid'] == other['uuid']
435 # If the tasks are not saved, compare the actual instances
436 return id(self) == id(other)
441 # For saved Tasks, just define equality by equality of uuids
442 return self['uuid'].__hash__()
444 # If the tasks are not saved, return hash of instance id
445 return id(self).__hash__()
449 return self['status'] == six.text_type('completed')
453 return self['status'] == six.text_type('deleted')
457 return self['status'] == six.text_type('waiting')
461 return self['status'] == six.text_type('pending')
465 return self['uuid'] is not None or self['id'] is not None
467 def serialize_depends(self, cur_dependencies):
468 # Check that all the tasks are saved
469 for task in (cur_dependencies or set()):
471 raise Task.NotSaved('Task \'%s\' needs to be saved before '
472 'it can be set as dependency.' % task)
474 return super(Task, self).serialize_depends(cur_dependencies)
476 def format_depends(self):
477 # We need to generate added and removed dependencies list,
478 # since Taskwarrior does not accept redefining dependencies.
480 # This cannot be part of serialize_depends, since we need
481 # to keep a list of all depedencies in the _data dictionary,
482 # not just currently added/removed ones
484 old_dependencies = self._original_data.get('depends', set())
486 added = self['depends'] - old_dependencies
487 removed = old_dependencies - self['depends']
489 # Removed dependencies need to be prefixed with '-'
490 return 'depends:' + ','.join(
491 [t['uuid'] for t in added] +
492 ['-' + t['uuid'] for t in removed]
495 def format_description(self):
496 # Task version older than 2.4.0 ignores first word of the
497 # task description if description: prefix is used
498 if self.warrior.version < VERSION_2_4_0:
499 return self._data['description']
501 return "description:'{0}'".format(self._data['description'] or '')
505 raise Task.NotSaved("Task needs to be saved before it can be deleted")
507 # Refresh the status, and raise exception if the task is deleted
508 self.refresh(only_fields=['status'])
511 raise Task.DeletedTask("Task was already deleted")
513 self.warrior.execute_command([self['uuid'], 'delete'])
515 # Refresh the status again, so that we have updated info stored
516 self.refresh(only_fields=['status'])
521 raise Task.NotSaved("Task needs to be saved before it can be completed")
523 # Refresh, and raise exception if task is already completed/deleted
524 self.refresh(only_fields=['status'])
527 raise Task.CompletedTask("Cannot complete a completed task")
529 raise Task.DeletedTask("Deleted task cannot be completed")
531 self.warrior.execute_command([self['uuid'], 'done'])
533 # Refresh the status again, so that we have updated info stored
534 self.refresh(only_fields=['status'])
537 if self.saved and not self.modified:
540 args = [self['uuid'], 'modify'] if self.saved else ['add']
541 args.extend(self._get_modified_fields_as_args())
542 output = self.warrior.execute_command(args)
544 # Parse out the new ID, if the task is being added for the first time
546 id_lines = [l for l in output if l.startswith('Created task ')]
548 # Complain loudly if it seems that more tasks were created
550 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
551 raise TaskWarriorException("Unexpected output when creating "
552 "task: %s" % '\n'.join(id_lines))
554 # Circumvent the ID storage, since ID is considered read-only
555 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
557 # Refreshing is very important here, as not only modification time
558 # is updated, but arbitrary attribute may have changed due hooks
559 # altering the data before saving
562 def add_annotation(self, annotation):
564 raise Task.NotSaved("Task needs to be saved to add annotation")
566 args = [self['uuid'], 'annotate', annotation]
567 self.warrior.execute_command(args)
568 self.refresh(only_fields=['annotations'])
570 def remove_annotation(self, annotation):
572 raise Task.NotSaved("Task needs to be saved to remove annotation")
574 if isinstance(annotation, TaskAnnotation):
575 annotation = annotation['description']
576 args = [self['uuid'], 'denotate', annotation]
577 self.warrior.execute_command(args)
578 self.refresh(only_fields=['annotations'])
580 def _get_modified_fields_as_args(self):
583 def add_field(field):
584 # Add the output of format_field method to args list (defaults to
586 serialized_value = self._serialize(field, self._data[field])
588 # Empty values should not be enclosed in quotation marks, see
590 if serialized_value is '':
591 escaped_serialized_value = ''
593 escaped_serialized_value = "'{0}'".format(serialized_value)
595 format_default = lambda: "{0}:{1}".format(field,
596 escaped_serialized_value)
598 format_func = getattr(self, 'format_{0}'.format(field),
601 args.append(format_func())
603 # If we're modifying saved task, simply pass on all modified fields
605 for field in self._modified_fields:
607 # For new tasks, pass all fields that make sense
609 for field in self._data.keys():
610 if field in self.read_only_fields:
616 def refresh(self, only_fields=[]):
617 # Raise error when trying to refresh a task that has not been saved
619 raise Task.NotSaved("Task needs to be saved to be refreshed")
621 # We need to use ID as backup for uuid here for the refreshes
622 # of newly saved tasks. Any other place in the code is fine
623 # with using UUID only.
624 args = [self['uuid'] or self['id'], 'export']
625 new_data = json.loads(self.warrior.execute_command(args)[0])
628 [(k, new_data.get(k)) for k in only_fields])
629 self._update_data(to_update, update_original=True)
631 self._load_data(new_data)
633 class TaskFilter(SerializingObject):
635 A set of parameters to filter the task list with.
638 def __init__(self, filter_params=[]):
639 self.filter_params = filter_params
641 def add_filter(self, filter_str):
642 self.filter_params.append(filter_str)
644 def add_filter_param(self, key, value):
645 key = key.replace('__', '.')
647 # Replace the value with empty string, since that is the
648 # convention in TW for empty values
649 attribute_key = key.split('.')[0]
651 # Since this is user input, we need to normalize before we serialize
652 value = self._normalize(key, value)
653 value = self._serialize(attribute_key, value)
655 # If we are filtering by uuid:, do not use uuid keyword
658 self.filter_params.insert(0, value)
660 # Surround value with aphostrophes unless it's a empty string
661 value = "'%s'" % value if value else ''
663 # We enforce equality match by using 'is' (or 'none') modifier
664 # Without using this syntax, filter fails due to TW-1479
665 modifier = '.is' if value else '.none'
666 key = key + modifier if '.' not in key else key
668 self.filter_params.append("{0}:{1}".format(key, value))
670 def get_filter_params(self):
671 return [f for f in self.filter_params if f]
675 c.filter_params = list(self.filter_params)
679 class TaskQuerySet(object):
681 Represents a lazy lookup for a task objects.
684 def __init__(self, warrior=None, filter_obj=None):
685 self.warrior = warrior
686 self._result_cache = None
687 self.filter_obj = filter_obj or TaskFilter()
689 def __deepcopy__(self, memo):
691 Deep copy of a QuerySet doesn't populate the cache
693 obj = self.__class__()
694 for k, v in self.__dict__.items():
695 if k in ('_iter', '_result_cache'):
696 obj.__dict__[k] = None
698 obj.__dict__[k] = copy.deepcopy(v, memo)
702 data = list(self[:REPR_OUTPUT_SIZE + 1])
703 if len(data) > REPR_OUTPUT_SIZE:
704 data[-1] = "...(remaining elements truncated)..."
708 if self._result_cache is None:
709 self._result_cache = list(self)
710 return len(self._result_cache)
713 if self._result_cache is None:
714 self._result_cache = self._execute()
715 return iter(self._result_cache)
717 def __getitem__(self, k):
718 if self._result_cache is None:
719 self._result_cache = list(self)
720 return self._result_cache.__getitem__(k)
723 if self._result_cache is not None:
724 return bool(self._result_cache)
727 except StopIteration:
731 def __nonzero__(self):
732 return type(self).__bool__(self)
734 def _clone(self, klass=None, **kwargs):
736 klass = self.__class__
737 filter_obj = self.filter_obj.clone()
738 c = klass(warrior=self.warrior, filter_obj=filter_obj)
739 c.__dict__.update(kwargs)
744 Fetch the tasks which match the current filters.
746 return self.warrior.filter_tasks(self.filter_obj)
750 Returns a new TaskQuerySet that is a copy of the current one.
755 return self.filter(status=PENDING)
758 return self.filter(status=COMPLETED)
760 def filter(self, *args, **kwargs):
762 Returns a new TaskQuerySet with the given filters added.
764 clone = self._clone()
766 clone.filter_obj.add_filter(f)
767 for key, value in kwargs.items():
768 clone.filter_obj.add_filter_param(key, value)
771 def get(self, **kwargs):
773 Performs the query and returns a single object matching the given
776 clone = self.filter(**kwargs)
779 return clone._result_cache[0]
781 raise Task.DoesNotExist(
782 'Task matching query does not exist. '
783 'Lookup parameters were {0}'.format(kwargs))
785 'get() returned more than one Task -- it returned {0}! '
786 'Lookup parameters were {1}'.format(num, kwargs))
789 class TaskWarrior(object):
790 def __init__(self, data_location='~/.task', create=True):
791 data_location = os.path.expanduser(data_location)
792 if create and not os.path.exists(data_location):
793 os.makedirs(data_location)
795 'data.location': os.path.expanduser(data_location),
796 'confirmation': 'no',
797 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
798 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
800 self.tasks = TaskQuerySet(self)
801 self.version = self._get_version()
803 def _get_command_args(self, args, config_override={}):
804 command_args = ['task', 'rc:/']
805 config = self.config.copy()
806 config.update(config_override)
807 for item in config.items():
808 command_args.append('rc.{0}={1}'.format(*item))
809 command_args.extend(map(str, args))
812 def _get_version(self):
813 p = subprocess.Popen(
814 ['task', '--version'],
815 stdout=subprocess.PIPE,
816 stderr=subprocess.PIPE)
817 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
818 return stdout.strip('\n')
820 def execute_command(self, args, config_override={}, allow_failure=True):
821 command_args = self._get_command_args(
822 args, config_override=config_override)
823 logger.debug(' '.join(command_args))
824 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
825 stderr=subprocess.PIPE)
826 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
827 if p.returncode and allow_failure:
829 error_msg = stderr.strip().splitlines()[-1]
831 error_msg = stdout.strip()
832 raise TaskWarriorException(error_msg)
833 return stdout.strip().split('\n')
835 def enforce_recurrence(self):
836 # Run arbitrary report command which will trigger generation
837 # of recurrent tasks.
838 # TODO: Make a version dependant enforcement once
840 self.execute_command(['next'], allow_failure=False)
842 def filter_tasks(self, filter_obj):
843 self.enforce_recurrence()
844 args = ['export', '--'] + filter_obj.get_filter_params()
846 for line in self.execute_command(args):
848 data = line.strip(',')
850 filtered_task = Task(self)
851 filtered_task._load_data(json.loads(data))
852 tasks.append(filtered_task)
854 raise TaskWarriorException('Invalid JSON: %s' % data)
857 def merge_with(self, path, push=False):
858 path = path.rstrip('/') + '/'
859 self.execute_command(['merge', path], config_override={
860 'merge.autopush': 'yes' if push else 'no',
864 self.execute_command(['undo'])