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 # Provide read only access to the original data
428 self.original = ReadOnlyDictView(self._original_data)
430 def __unicode__(self):
431 return self['description']
433 def __eq__(self, other):
434 if self['uuid'] and other['uuid']:
435 # For saved Tasks, just define equality by equality of uuids
436 return self['uuid'] == other['uuid']
438 # If the tasks are not saved, compare the actual instances
439 return id(self) == id(other)
444 # For saved Tasks, just define equality by equality of uuids
445 return self['uuid'].__hash__()
447 # If the tasks are not saved, return hash of instance id
448 return id(self).__hash__()
452 return self['status'] == six.text_type('completed')
456 return self['status'] == six.text_type('deleted')
460 return self['status'] == six.text_type('waiting')
464 return self['status'] == six.text_type('pending')
468 return self['uuid'] is not None or self['id'] is not None
470 def serialize_depends(self, cur_dependencies):
471 # Check that all the tasks are saved
472 for task in (cur_dependencies or set()):
474 raise Task.NotSaved('Task \'%s\' needs to be saved before '
475 'it can be set as dependency.' % task)
477 return super(Task, self).serialize_depends(cur_dependencies)
479 def format_depends(self):
480 # We need to generate added and removed dependencies list,
481 # since Taskwarrior does not accept redefining dependencies.
483 # This cannot be part of serialize_depends, since we need
484 # to keep a list of all depedencies in the _data dictionary,
485 # not just currently added/removed ones
487 old_dependencies = self._original_data.get('depends', set())
489 added = self['depends'] - old_dependencies
490 removed = old_dependencies - self['depends']
492 # Removed dependencies need to be prefixed with '-'
493 return 'depends:' + ','.join(
494 [t['uuid'] for t in added] +
495 ['-' + t['uuid'] for t in removed]
498 def format_description(self):
499 # Task version older than 2.4.0 ignores first word of the
500 # task description if description: prefix is used
501 if self.warrior.version < VERSION_2_4_0:
502 return self._data['description']
504 return "description:'{0}'".format(self._data['description'] or '')
508 raise Task.NotSaved("Task needs to be saved before it can be deleted")
510 # Refresh the status, and raise exception if the task is deleted
511 self.refresh(only_fields=['status'])
514 raise Task.DeletedTask("Task was already deleted")
516 self.warrior.execute_command([self['uuid'], 'delete'])
518 # Refresh the status again, so that we have updated info stored
519 self.refresh(only_fields=['status'])
524 raise Task.NotSaved("Task needs to be saved before it can be completed")
526 # Refresh, and raise exception if task is already completed/deleted
527 self.refresh(only_fields=['status'])
530 raise Task.CompletedTask("Cannot complete a completed task")
532 raise Task.DeletedTask("Deleted task cannot be completed")
534 self.warrior.execute_command([self['uuid'], 'done'])
536 # Refresh the status again, so that we have updated info stored
537 self.refresh(only_fields=['status'])
540 if self.saved and not self.modified:
543 args = [self['uuid'], 'modify'] if self.saved else ['add']
544 args.extend(self._get_modified_fields_as_args())
545 output = self.warrior.execute_command(args)
547 # Parse out the new ID, if the task is being added for the first time
549 id_lines = [l for l in output if l.startswith('Created task ')]
551 # Complain loudly if it seems that more tasks were created
553 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
554 raise TaskWarriorException("Unexpected output when creating "
555 "task: %s" % '\n'.join(id_lines))
557 # Circumvent the ID storage, since ID is considered read-only
558 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
560 # Refreshing is very important here, as not only modification time
561 # is updated, but arbitrary attribute may have changed due hooks
562 # altering the data before saving
565 def add_annotation(self, annotation):
567 raise Task.NotSaved("Task needs to be saved to add annotation")
569 args = [self['uuid'], 'annotate', annotation]
570 self.warrior.execute_command(args)
571 self.refresh(only_fields=['annotations'])
573 def remove_annotation(self, annotation):
575 raise Task.NotSaved("Task needs to be saved to remove annotation")
577 if isinstance(annotation, TaskAnnotation):
578 annotation = annotation['description']
579 args = [self['uuid'], 'denotate', annotation]
580 self.warrior.execute_command(args)
581 self.refresh(only_fields=['annotations'])
583 def _get_modified_fields_as_args(self):
586 def add_field(field):
587 # Add the output of format_field method to args list (defaults to
589 serialized_value = self._serialize(field, self._data[field])
591 # Empty values should not be enclosed in quotation marks, see
593 if serialized_value is '':
594 escaped_serialized_value = ''
596 escaped_serialized_value = "'{0}'".format(serialized_value)
598 format_default = lambda: "{0}:{1}".format(field,
599 escaped_serialized_value)
601 format_func = getattr(self, 'format_{0}'.format(field),
604 args.append(format_func())
606 # If we're modifying saved task, simply pass on all modified fields
608 for field in self._modified_fields:
610 # For new tasks, pass all fields that make sense
612 for field in self._data.keys():
613 if field in self.read_only_fields:
619 def refresh(self, only_fields=[]):
620 # Raise error when trying to refresh a task that has not been saved
622 raise Task.NotSaved("Task needs to be saved to be refreshed")
624 # We need to use ID as backup for uuid here for the refreshes
625 # of newly saved tasks. Any other place in the code is fine
626 # with using UUID only.
627 args = [self['uuid'] or self['id'], 'export']
628 new_data = json.loads(self.warrior.execute_command(args)[0])
631 [(k, new_data.get(k)) for k in only_fields])
632 self._update_data(to_update, update_original=True)
634 self._load_data(new_data)
636 class TaskFilter(SerializingObject):
638 A set of parameters to filter the task list with.
641 def __init__(self, filter_params=[]):
642 self.filter_params = filter_params
644 def add_filter(self, filter_str):
645 self.filter_params.append(filter_str)
647 def add_filter_param(self, key, value):
648 key = key.replace('__', '.')
650 # Replace the value with empty string, since that is the
651 # convention in TW for empty values
652 attribute_key = key.split('.')[0]
654 # Since this is user input, we need to normalize before we serialize
655 value = self._normalize(key, value)
656 value = self._serialize(attribute_key, value)
658 # If we are filtering by uuid:, do not use uuid keyword
661 self.filter_params.insert(0, value)
663 # Surround value with aphostrophes unless it's a empty string
664 value = "'%s'" % value if value else ''
666 # We enforce equality match by using 'is' (or 'none') modifier
667 # Without using this syntax, filter fails due to TW-1479
668 modifier = '.is' if value else '.none'
669 key = key + modifier if '.' not in key else key
671 self.filter_params.append("{0}:{1}".format(key, value))
673 def get_filter_params(self):
674 return [f for f in self.filter_params if f]
678 c.filter_params = list(self.filter_params)
682 class TaskQuerySet(object):
684 Represents a lazy lookup for a task objects.
687 def __init__(self, warrior=None, filter_obj=None):
688 self.warrior = warrior
689 self._result_cache = None
690 self.filter_obj = filter_obj or TaskFilter()
692 def __deepcopy__(self, memo):
694 Deep copy of a QuerySet doesn't populate the cache
696 obj = self.__class__()
697 for k, v in self.__dict__.items():
698 if k in ('_iter', '_result_cache'):
699 obj.__dict__[k] = None
701 obj.__dict__[k] = copy.deepcopy(v, memo)
705 data = list(self[:REPR_OUTPUT_SIZE + 1])
706 if len(data) > REPR_OUTPUT_SIZE:
707 data[-1] = "...(remaining elements truncated)..."
711 if self._result_cache is None:
712 self._result_cache = list(self)
713 return len(self._result_cache)
716 if self._result_cache is None:
717 self._result_cache = self._execute()
718 return iter(self._result_cache)
720 def __getitem__(self, k):
721 if self._result_cache is None:
722 self._result_cache = list(self)
723 return self._result_cache.__getitem__(k)
726 if self._result_cache is not None:
727 return bool(self._result_cache)
730 except StopIteration:
734 def __nonzero__(self):
735 return type(self).__bool__(self)
737 def _clone(self, klass=None, **kwargs):
739 klass = self.__class__
740 filter_obj = self.filter_obj.clone()
741 c = klass(warrior=self.warrior, filter_obj=filter_obj)
742 c.__dict__.update(kwargs)
747 Fetch the tasks which match the current filters.
749 return self.warrior.filter_tasks(self.filter_obj)
753 Returns a new TaskQuerySet that is a copy of the current one.
758 return self.filter(status=PENDING)
761 return self.filter(status=COMPLETED)
763 def filter(self, *args, **kwargs):
765 Returns a new TaskQuerySet with the given filters added.
767 clone = self._clone()
769 clone.filter_obj.add_filter(f)
770 for key, value in kwargs.items():
771 clone.filter_obj.add_filter_param(key, value)
774 def get(self, **kwargs):
776 Performs the query and returns a single object matching the given
779 clone = self.filter(**kwargs)
782 return clone._result_cache[0]
784 raise Task.DoesNotExist(
785 'Task matching query does not exist. '
786 'Lookup parameters were {0}'.format(kwargs))
788 'get() returned more than one Task -- it returned {0}! '
789 'Lookup parameters were {1}'.format(num, kwargs))
792 class TaskWarrior(object):
793 def __init__(self, data_location='~/.task', create=True):
794 data_location = os.path.expanduser(data_location)
795 if create and not os.path.exists(data_location):
796 os.makedirs(data_location)
798 'data.location': os.path.expanduser(data_location),
799 'confirmation': 'no',
800 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
801 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
803 self.tasks = TaskQuerySet(self)
804 self.version = self._get_version()
806 def _get_command_args(self, args, config_override={}):
807 command_args = ['task', 'rc:/']
808 config = self.config.copy()
809 config.update(config_override)
810 for item in config.items():
811 command_args.append('rc.{0}={1}'.format(*item))
812 command_args.extend(map(str, args))
815 def _get_version(self):
816 p = subprocess.Popen(
817 ['task', '--version'],
818 stdout=subprocess.PIPE,
819 stderr=subprocess.PIPE)
820 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
821 return stdout.strip('\n')
823 def execute_command(self, args, config_override={}, allow_failure=True):
824 command_args = self._get_command_args(
825 args, config_override=config_override)
826 logger.debug(' '.join(command_args))
827 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
828 stderr=subprocess.PIPE)
829 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
830 if p.returncode and allow_failure:
832 error_msg = stderr.strip().splitlines()[-1]
834 error_msg = stdout.strip()
835 raise TaskWarriorException(error_msg)
836 return stdout.strip().split('\n')
838 def enforce_recurrence(self):
839 # Run arbitrary report command which will trigger generation
840 # of recurrent tasks.
841 # TODO: Make a version dependant enforcement once
843 self.execute_command(['next'], allow_failure=False)
845 def filter_tasks(self, filter_obj):
846 self.enforce_recurrence()
847 args = ['export', '--'] + filter_obj.get_filter_params()
849 for line in self.execute_command(args):
851 data = line.strip(',')
853 filtered_task = Task(self)
854 filtered_task._load_data(json.loads(data))
855 tasks.append(filtered_task)
857 raise TaskWarriorException('Invalid JSON: %s' % data)
860 def merge_with(self, path, push=False):
861 path = path.rstrip('/') + '/'
862 self.execute_command(['merge', path], config_override={
863 'merge.autopush': 'yes' if push else 'no',
867 self.execute_command(['undo'])