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):
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))
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_<key> 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 = []
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
# 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):
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)
__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']
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
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()))
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():
# 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
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")
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'])
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):
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])
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
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
"""
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):
"""