X-Git-Url: https://git.madduck.net/etc/taskwarrior.git/blobdiff_plain/dcbdcdd11c981b4600ea4a5522230d0456e45d21..238c0ba7d462d67f8b847bee06233a7fd20893c4:/tasklib/task.py?ds=sidebyside diff --git a/tasklib/task.py b/tasklib/task.py index b7bba4b..6ebee4c 100644 --- a/tasklib/task.py +++ b/tasklib/task.py @@ -1,24 +1,20 @@ from __future__ import print_function import copy -import datetime +import importlib import json import logging import os -import pytz import six import sys -import tzlocal -from backends import TaskWarrior, TaskWarriorException +from .serializing import SerializingObject DATE_FORMAT = '%Y%m%dT%H%M%SZ' -DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S' REPR_OUTPUT_SIZE = 10 PENDING = 'pending' COMPLETED = 'completed' logger = logging.getLogger(__name__) -local_zone = tzlocal.get_localzone() class ReadOnlyDictView(object): @@ -42,6 +38,11 @@ class ReadOnlyDictView(object): def __len__(self): return len(self.viewed_dict) + def __unicode__(self): + return six.u('ReadOnlyDictView: {0}'.format(repr(self.viewed_dict))) + + __repr__ = __unicode__ + def get(self, key, default=None): return copy.deepcopy(self.viewed_dict.get(key, default)) @@ -52,237 +53,6 @@ class ReadOnlyDictView(object): return [copy.deepcopy(v) for v in self.viewed_dict.values()] -class SerializingObject(object): - """ - Common ancestor for TaskResource & TaskFilter, since they both - need to serialize arguments. - - Serializing method should hold the following contract: - - any empty value (meaning removal of the attribute) - is deserialized into a empty string - - None denotes a empty value for any attribute - - Deserializing method should hold the following contract: - - None denotes a empty value for any attribute (however, - this is here as a safeguard, TaskWarrior currently does - not export empty-valued attributes) if the attribute - is not iterable (e.g. list or set), in which case - a empty iterable should be used. - - Normalizing methods should hold the following contract: - - They are used to validate and normalize the user input. - Any attribute value that comes from the user (during Task - initialization, assignign values to Task attributes, or - filtering by user-provided values of attributes) is first - validated and normalized using the normalize_{key} method. - - If validation or normalization fails, normalizer is expected - to raise ValueError. - """ - - def __init__(self, warrior): - self.warrior = warrior - - def _deserialize(self, key, value): - hydrate_func = getattr(self, 'deserialize_{0}'.format(key), - lambda x: x if x != '' else None) - return hydrate_func(value) - - def _serialize(self, key, value): - dehydrate_func = getattr(self, 'serialize_{0}'.format(key), - lambda x: x if x is not None else '') - return dehydrate_func(value) - - def _normalize(self, key, value): - """ - Use normalize_ methods to normalize user input. Any user - input will be normalized at the moment it is used as filter, - or entered as a value of Task attribute. - """ - - # None value should not be converted by normalizer - if value is None: - return None - - normalize_func = getattr(self, 'normalize_{0}'.format(key), - lambda x: x) - - return normalize_func(value) - - def timestamp_serializer(self, date): - if not date: - return '' - - # Any serialized timestamp should be localized, we need to - # convert to UTC before converting to string (DATE_FORMAT uses UTC) - date = date.astimezone(pytz.utc) - - return date.strftime(DATE_FORMAT) - - def timestamp_deserializer(self, date_str): - if not date_str: - return None - - # Return timestamp localized in the local zone - naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT) - localized_timestamp = pytz.utc.localize(naive_timestamp) - return localized_timestamp.astimezone(local_zone) - - def serialize_entry(self, value): - return self.timestamp_serializer(value) - - def deserialize_entry(self, value): - return self.timestamp_deserializer(value) - - def normalize_entry(self, value): - return self.datetime_normalizer(value) - - def serialize_modified(self, value): - return self.timestamp_serializer(value) - - def deserialize_modified(self, value): - return self.timestamp_deserializer(value) - - def normalize_modified(self, value): - return self.datetime_normalizer(value) - - def serialize_start(self, value): - return self.timestamp_serializer(value) - - def deserialize_start(self, value): - return self.timestamp_deserializer(value) - - def normalize_start(self, value): - return self.datetime_normalizer(value) - - def serialize_end(self, value): - return self.timestamp_serializer(value) - - def deserialize_end(self, value): - return self.timestamp_deserializer(value) - - def normalize_end(self, value): - return self.datetime_normalizer(value) - - def serialize_due(self, value): - return self.timestamp_serializer(value) - - def deserialize_due(self, value): - return self.timestamp_deserializer(value) - - def normalize_due(self, value): - return self.datetime_normalizer(value) - - def serialize_scheduled(self, value): - return self.timestamp_serializer(value) - - def deserialize_scheduled(self, value): - return self.timestamp_deserializer(value) - - def normalize_scheduled(self, value): - return self.datetime_normalizer(value) - - def serialize_until(self, value): - return self.timestamp_serializer(value) - - def deserialize_until(self, value): - return self.timestamp_deserializer(value) - - def normalize_until(self, value): - return self.datetime_normalizer(value) - - def serialize_wait(self, value): - return self.timestamp_serializer(value) - - def deserialize_wait(self, value): - return self.timestamp_deserializer(value) - - def normalize_wait(self, value): - return self.datetime_normalizer(value) - - def serialize_annotations(self, value): - value = value if value is not None else [] - - # This may seem weird, but it's correct, we want to export - # a list of dicts as serialized value - serialized_annotations = [json.loads(annotation.export_data()) - for annotation in value] - return serialized_annotations if serialized_annotations else '' - - def deserialize_annotations(self, data): - return [TaskAnnotation(self, d) for d in data] if data else [] - - def serialize_tags(self, tags): - return ','.join(tags) if tags else '' - - def deserialize_tags(self, tags): - if isinstance(tags, six.string_types): - return tags.split(',') if tags else [] - return tags or [] - - def serialize_depends(self, value): - # Return the list of uuids - value = value if value is not None else set() - return ','.join(task['uuid'] for task in value) - - def deserialize_depends(self, raw_uuids): - raw_uuids = raw_uuids or [] # Convert None to empty list - - # TW 2.4.4 encodes list of dependencies as a single string - if type(raw_uuids) is not list: - uuids = raw_uuids.split(',') - # TW 2.4.5 and later exports them as a list, no conversion needed - else: - uuids = raw_uuids - - return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid) - - def datetime_normalizer(self, value): - """ - Normalizes date/datetime value (considered to come from user input) - to localized datetime value. Following conversions happen: - - naive date -> localized datetime with the same date, and time=midnight - naive datetime -> localized datetime with the same value - localized datetime -> localized datetime (no conversion) - """ - - if (isinstance(value, datetime.date) - and not isinstance(value, datetime.datetime)): - # Convert to local midnight - value_full = datetime.datetime.combine(value, datetime.time.min) - localized = local_zone.localize(value_full) - elif isinstance(value, datetime.datetime): - if value.tzinfo is None: - # Convert to localized datetime object - localized = local_zone.localize(value) - else: - # If the value is already localized, there is no need to change - # time zone at this point. Also None is a valid value too. - localized = value - elif (isinstance(value, six.string_types) - and self.warrior.version >= VERSION_2_4_0): - # For strings, use 'task calc' to evaluate the string to datetime - # available since TW 2.4.0 - args = value.split() - result = self.warrior.execute_command(['calc'] + args) - naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC) - localized = local_zone.localize(naive) - else: - raise ValueError("Provided value could not be converted to " - "datetime, its type is not supported: {}" - .format(type(value))) - - return localized - - def normalize_uuid(self, value): - # Enforce sane UUID - if not isinstance(value, six.string_types) or value == '': - raise ValueError("UUID must be a valid non-empty string, " - "not: {}".format(value)) - - return value - - class TaskResource(SerializingObject): read_only_fields = [] @@ -310,7 +80,6 @@ class TaskResource(SerializingObject): if update_original: self._original_data = copy.deepcopy(self._data) - def __getitem__(self, key): # This is a workaround to make TaskResource non-iterable # over simple index-based iteration @@ -355,7 +124,7 @@ class TaskResource(SerializingObject): # to pass that to TaskWarrior. data_tuples = filter(lambda t: t[1] is not '', data_tuples) data = dict(data_tuples) - return json.dumps(data, separators=(',',':')) + return json.dumps(data, separators=(',', ':')) @property def _modified_fields(self): @@ -383,7 +152,7 @@ class TaskAnnotation(TaskResource): def __init__(self, task, data=None): self.task = task self._load_data(data or dict()) - super(TaskAnnotation, self).__init__(task.warrior) + super(TaskAnnotation, self).__init__(task.backend) def remove(self): self.task.remove_annotation(self) @@ -399,6 +168,97 @@ class TaskAnnotation(TaskResource): __repr__ = __unicode__ +class LazyUUIDTask(object): + """ + A lazy wrapper around Task object, referenced by UUID. + + - Supports comparison with LazyUUIDTask or Task objects (equality by UUIDs) + - If any attribute other than 'uuid' requested, a lookup in the + backend will be performed and this object will be replaced by a proper + Task object. + """ + + def __init__(self, tw, uuid): + self._tw = tw + self._uuid = uuid + + def __getitem__(self, key): + # LazyUUIDTask does not provide anything else other than 'uuid' + if key is 'uuid': + return self._uuid + else: + self.replace() + return self[key] + + def __getattr__(self, name): + # Getattr is called only if the attribute could not be found using + # normal means + self.replace() + return self.name + + def __eq__(self, other): + if other['uuid']: + # For saved Tasks, just define equality by equality of uuids + return self['uuid'] == other['uuid'] + + def __hash__(self): + return self['uuid'].__hash__() + + def replace(self): + """ + Performs conversion to the regular Task object, referenced by the + stored UUID. + """ + + replacement = self._tw.tasks.get(uuid=self._uuid) + self.__class__ = replacement.__class__ + self.__dict__ = replacement.__dict__ + + +class LazyUUIDTaskSet(object): + """ + A lazy wrapper around TaskQuerySet object, for tasks referenced by UUID. + + - Supports 'in' operator with LazyUUIDTask or Task objects + - If iteration over the objects in the LazyUUIDTaskSet is requested, the + LazyUUIDTaskSet will be converted to QuerySet and evaluated + """ + + def __init__(self, tw, uuids): + self._tw = tw + self._uuids = set(uuids) + + def __getattr__(self, name): + # Getattr is called only if the attribute could not be found using + # normal means + self.replace() + return self.name + + def __eq__(self, other): + return set(t['uuid'] for t in other) == self._uuids + + def __contains__(self, task): + return task['uuid'] in self._uuids + + def __len__(self): + return len(self._uuids) + + def __iter__(self): + self.replace() + for task in self: + yield task + + def replace(self): + """ + Performs conversion to the regular TaskQuerySet object, referenced by + the stored UUIDs. + """ + + replacement = self._tw.tasks.filter(' '.join(self._uuids)) + self.__class__ = replacement.__class__ + self.__dict__ = replacement.__dict__ + + class Task(TaskResource): read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified'] @@ -437,7 +297,7 @@ class Task(TaskResource): pass @classmethod - def from_input(cls, input_file=sys.stdin, modify=None, warrior=None): + def from_input(cls, input_file=sys.stdin, modify=None, backend=None): """ Creates a Task object, directly from the stdin, by reading one line. If modify=True, two lines are used, first line interpreted as the @@ -458,12 +318,13 @@ class Task(TaskResource): modify = name.startswith('on-modify') if modify is None else modify # Create the TaskWarrior instance if none passed - if warrior is None: + if backend is None: + backends = importlib.import_module('tasklib.backends') hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0])) - warrior = TaskWarrior(data_location=hook_parent_dir) + backend = backends.TaskWarrior(data_location=hook_parent_dir) # TaskWarrior instance is set to None - task = cls(warrior) + task = cls(backend) # Load the data from the input task._load_data(json.loads(input_file.readline().strip())) @@ -476,8 +337,8 @@ class Task(TaskResource): return task - def __init__(self, warrior, **kwargs): - super(Task, self).__init__(warrior) + def __init__(self, backend, **kwargs): + super(Task, self).__init__(backend) # Check that user is not able to set read-only value in __init__ for key in kwargs.keys(): @@ -507,7 +368,6 @@ class Task(TaskResource): # If the tasks are not saved, compare the actual instances return id(self) == id(other) - def __hash__(self): if self['uuid']: # For saved Tasks, just define equality by equality of uuids @@ -549,33 +409,6 @@ class Task(TaskResource): return super(Task, self).serialize_depends(cur_dependencies) - def format_depends(self): - # We need to generate added and removed dependencies list, - # since Taskwarrior does not accept redefining dependencies. - - # This cannot be part of serialize_depends, since we need - # to keep a list of all depedencies in the _data dictionary, - # not just currently added/removed ones - - old_dependencies = self._original_data.get('depends', set()) - - added = self['depends'] - old_dependencies - removed = old_dependencies - self['depends'] - - # Removed dependencies need to be prefixed with '-' - return 'depends:' + ','.join( - [t['uuid'] for t in added] + - ['-' + t['uuid'] for t in removed] - ) - - def format_description(self): - # Task version older than 2.4.0 ignores first word of the - # task description if description: prefix is used - if self.warrior.version < VERSION_2_4_0: - return self._data['description'] - else: - return six.u("description:'{0}'").format(self._data['description'] or '') - def delete(self): if not self.saved: raise Task.NotSaved("Task needs to be saved before it can be deleted") @@ -637,11 +470,7 @@ class Task(TaskResource): elif self.deleted: raise Task.DeletedTask("Deleted task cannot be completed") - # Older versions of TW do not stop active task at completion - if self.warrior.version < VERSION_2_4_0 and self.active: - self.stop() - - self.warrior.execute_command([self['uuid'], 'done']) + self.backend.complete_task(self) # Refresh the status again, so that we have updated info stored self.refresh(only_fields=['status', 'start', 'end']) @@ -657,8 +486,7 @@ class Task(TaskResource): if not self.saved: raise Task.NotSaved("Task needs to be saved to add annotation") - args = [self['uuid'], 'annotate', annotation] - self.warrior.execute_command(args) + self.backend.annotate_task(self, annotation) self.refresh(only_fields=['annotations']) def remove_annotation(self, annotation): @@ -667,88 +495,17 @@ class Task(TaskResource): if isinstance(annotation, TaskAnnotation): annotation = annotation['description'] - args = [self['uuid'], 'denotate', annotation] - self.warrior.execute_command(args) - self.refresh(only_fields=['annotations']) - - def _get_modified_fields_as_args(self): - args = [] - - def add_field(field): - # Add the output of format_field method to args list (defaults to - # field:value) - serialized_value = self._serialize(field, self._data[field]) - - # Empty values should not be enclosed in quotation marks, see - # TW-1510 - if serialized_value is '': - escaped_serialized_value = '' - else: - escaped_serialized_value = six.u("'{0}'").format(serialized_value) - - format_default = lambda: six.u("{0}:{1}").format(field, - escaped_serialized_value) - - format_func = getattr(self, 'format_{0}'.format(field), - format_default) - - args.append(format_func()) - - # If we're modifying saved task, simply pass on all modified fields - if self.saved: - for field in self._modified_fields: - add_field(field) - # For new tasks, pass all fields that make sense - else: - for field in self._data.keys(): - if field in self.read_only_fields: - continue - add_field(field) - return args + self.backend.denotate_task(self, annotation) + self.refresh(only_fields=['annotations']) def refresh(self, only_fields=None, after_save=False): # Raise error when trying to refresh a task that has not been saved if not self.saved: raise Task.NotSaved("Task needs to be saved to be refreshed") - # We need to use ID as backup for uuid here for the refreshes - # of newly saved tasks. Any other place in the code is fine - # with using UUID only. - args = [self['uuid'] or self['id'], 'export'] - output = self.warrior.execute_command(args) - - def valid(output): - return len(output) == 1 and output[0].startswith('{') - - # For older TW versions attempt to uniquely locate the task - # using the data we have if it has been just saved. - # This can happen when adding a completed task on older TW versions. - if (not valid(output) and self.warrior.version < VERSION_2_4_5 - and after_save): - - # Make a copy, removing ID and UUID. It's most likely invalid - # (ID 0) if it failed to match a unique task. - data = copy.deepcopy(self._data) - data.pop('id', None) - data.pop('uuid', None) - - taskfilter = TaskFilter(self.warrior) - for key, value in data.items(): - taskfilter.add_filter_param(key, value) - - output = self.warrior.execute_command(['export', '--'] + - taskfilter.get_filter_params()) - - # If more than 1 task has been matched still, raise an exception - if not valid(output): - raise TaskWarriorException( - "Unique identifiers {0} with description: {1} matches " - "multiple tasks: {2}".format( - self['uuid'] or self['id'], self['description'], output) - ) - - new_data = json.loads(output[0]) + new_data = self.backend.refresh_task(self, after_save=after_save) + if only_fields: to_update = dict( [(k, new_data.get(k)) for k in only_fields]) @@ -756,70 +513,22 @@ class Task(TaskResource): else: self._load_data(new_data) -class TaskFilter(SerializingObject): - """ - A set of parameters to filter the task list with. - """ - - def __init__(self, warrior, filter_params=None): - self.filter_params = filter_params or [] - super(TaskFilter, self).__init__(warrior) - - def add_filter(self, filter_str): - self.filter_params.append(filter_str) - - def add_filter_param(self, key, value): - key = key.replace('__', '.') - - # Replace the value with empty string, since that is the - # convention in TW for empty values - attribute_key = key.split('.')[0] - - # Since this is user input, we need to normalize before we serialize - value = self._normalize(attribute_key, value) - value = self._serialize(attribute_key, value) - - # If we are filtering by uuid:, do not use uuid keyword - # due to TW-1452 bug - if key == 'uuid': - self.filter_params.insert(0, value) - else: - # Surround value with aphostrophes unless it's a empty string - value = "'%s'" % value if value else '' - - # We enforce equality match by using 'is' (or 'none') modifier - # Without using this syntax, filter fails due to TW-1479 - # which is, however, fixed in 2.4.5 - if self.warrior.version < VERSION_2_4_5: - modifier = '.is' if value else '.none' - key = key + modifier if '.' not in key else key - - self.filter_params.append(six.u("{0}:{1}").format(key, value)) - - def get_filter_params(self): - return [f for f in self.filter_params if f] - - def clone(self): - c = self.__class__(self.warrior) - c.filter_params = list(self.filter_params) - return c - class TaskQuerySet(object): """ Represents a lazy lookup for a task objects. """ - def __init__(self, warrior=None, filter_obj=None): - self.warrior = warrior + def __init__(self, backend, filter_obj=None): + self.backend = backend self._result_cache = None - self.filter_obj = filter_obj or TaskFilter(warrior) + self.filter_obj = filter_obj or self.backend.filter_class(backend) def __deepcopy__(self, memo): """ Deep copy of a QuerySet doesn't populate the cache """ - obj = self.__class__() + obj = self.__class__(backend=self.backend) for k, v in self.__dict__.items(): if k in ('_iter', '_result_cache'): obj.__dict__[k] = None @@ -864,7 +573,7 @@ class TaskQuerySet(object): if klass is None: klass = self.__class__ filter_obj = self.filter_obj.clone() - c = klass(warrior=self.warrior, filter_obj=filter_obj) + c = klass(backend=self.backend, filter_obj=filter_obj) c.__dict__.update(kwargs) return c @@ -872,7 +581,7 @@ class TaskQuerySet(object): """ Fetch the tasks which match the current filters. """ - return self.warrior.filter_tasks(self.filter_obj) + return self.backend.filter_tasks(self.filter_obj) def all(self): """