+class ReadOnlyDictView(object):
+ """
+ Provides simplified read-only view upon dict object.
+ """
+
+ def __init__(self, viewed_dict):
+ self.viewed_dict = viewed_dict
+
+ def __getitem__(self, key):
+ return copy.deepcopy(self.viewed_dict.__getitem__(key))
+
+ def __contains__(self, k):
+ return self.viewed_dict.__contains__(k)
+
+ def __iter__(self):
+ for value in self.viewed_dict:
+ yield copy.deepcopy(value)
+
+ def __len__(self):
+ return len(self.viewed_dict)
+
+ def get(self, key, default=None):
+ return copy.deepcopy(self.viewed_dict.get(key, default))
+
+ def items(self):
+ return [copy.deepcopy(v) for v in self.viewed_dict.items()]
+
+ def values(self):
+ 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 _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_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 string
+ uuids = raw_uuids.split(',')
+ 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) and 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
+
+ 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 = []
+
+ def _load_data(self, data):
+ self._data = dict((key, self._deserialize(key, value))
+ for key, value in data.items())
+ # We need to use a copy for original data, so that changes
+ # are not propagated.
+ self._original_data = copy.deepcopy(self._data)
+
+ def _update_data(self, data, update_original=False):
+ """
+ Low level update of the internal _data dict. Data which are coming as
+ updates should already be serialized. If update_original is True, the
+ original_data dict is updated as well.
+ """
+ self._data.update(dict((key, self._deserialize(key, value))
+ for key, value in data.items()))
+
+ 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
+ try:
+ int(key)
+ raise StopIteration
+ except ValueError:
+ pass
+
+ if key not in self._data:
+ self._data[key] = self._deserialize(key, None)
+
+ return self._data.get(key)
+
+ def __setitem__(self, key, value):
+ if key in self.read_only_fields:
+ raise RuntimeError('Field \'%s\' is read-only' % key)
+
+ # Normalize the user input before saving it
+ value = self._normalize(key, value)
+ self._data[key] = value
+
+ def __str__(self):
+ s = six.text_type(self.__unicode__())
+ if not six.PY3:
+ s = s.encode('utf-8')
+ return s
+
+ def __repr__(self):
+ return str(self)
+
+ def export_data(self):
+ """
+ Exports current data contained in the Task as JSON
+ """
+
+ # We need to remove spaces for TW-1504, use custom separators
+ data_tuples = ((key, self._serialize(key, value))
+ for key, value in six.iteritems(self._data))
+
+ # Empty string denotes empty serialized value, we do not want
+ # 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=(',',':'))
+
+ @property
+ def _modified_fields(self):
+ writable_fields = set(self._data.keys()) - set(self.read_only_fields)
+ for key in writable_fields:
+ new_value = self._data.get(key)
+ old_value = self._original_data.get(key)
+
+ # Make sure not to mark data removal as modified field if the
+ # field originally had some empty value
+ if key in self._data and not new_value and not old_value:
+ continue
+
+ if new_value != old_value:
+ yield key
+
+ @property
+ def modified(self):
+ return bool(list(self._modified_fields))
+
+
+class TaskAnnotation(TaskResource):
+ read_only_fields = ['entry', 'description']
+
+ def __init__(self, task, data={}):
+ self.task = task
+ self._load_data(data)
+
+ def remove(self):
+ self.task.remove_annotation(self)
+
+ def __unicode__(self):
+ return self['description']
+
+ def __eq__(self, other):
+ # consider 2 annotations equal if they belong to the same task, and
+ # their data dics are the same
+ return self.task == other.task and self._data == other._data
+
+ __repr__ = __unicode__
+
+
+class Task(TaskResource):
+ read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']