]> git.madduck.net Git - etc/taskwarrior.git/blobdiff - tasklib/task.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

Setup: Add tzlocal to required packages
[etc/taskwarrior.git] / tasklib / task.py
index 3860f8efd7b7309b409ee9fd0fbcfa5ac83e49ef..73280b0cf447bb6e1f1f246914ac0916ae2130ec 100644 (file)
@@ -4,8 +4,11 @@ import datetime
 import json
 import logging
 import os
 import json
 import logging
 import os
+import pytz
 import six
 import six
+import sys
 import subprocess
 import subprocess
+import tzlocal
 
 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
 REPR_OUTPUT_SIZE = 10
 
 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
 REPR_OUTPUT_SIZE = 10
@@ -18,30 +21,227 @@ VERSION_2_3_0 = six.u('2.3.0')
 VERSION_2_4_0 = six.u('2.4.0')
 
 logger = logging.getLogger(__name__)
 VERSION_2_4_0 = six.u('2.4.0')
 
 logger = logging.getLogger(__name__)
+local_zone = tzlocal.get_localzone()
 
 
 class TaskWarriorException(Exception):
     pass
 
 
 
 
 class TaskWarriorException(Exception):
     pass
 
 
-class TaskResource(object):
+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.
+    """
+
+    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.
+        """
+
+        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
+            
+
+
+class TaskResource(SerializingObject):
     read_only_fields = []
 
     def _load_data(self, data):
     read_only_fields = []
 
     def _load_data(self, data):
-        self._data = data
-        self._original_data = 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):
 
     def __getitem__(self, key):
-        hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
-                               lambda x: x)
-        return hydrate_func(self._data.get(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)
 
     def __setitem__(self, key, value):
         if key in self.read_only_fields:
             raise RuntimeError('Field \'%s\' is read-only' % key)
-        dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
-                                 lambda x: x)
-        self._data[key] = dehydrate_func(value)
+
+        # 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__())
 
     def __str__(self):
         s = six.text_type(self.__unicode__())
@@ -52,6 +252,40 @@ class TaskResource(object):
     def __repr__(self):
         return str(self)
 
     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']
 
 class TaskAnnotation(TaskResource):
     read_only_fields = ['entry', 'description']
@@ -60,23 +294,22 @@ class TaskAnnotation(TaskResource):
         self.task = task
         self._load_data(data)
 
         self.task = task
         self._load_data(data)
 
-    def deserialize_entry(self, data):
-        return datetime.datetime.strptime(data, DATE_FORMAT) if data else None
-
-    def serialize_entry(self, date):
-        return date.strftime(DATE_FORMAT) if date else ''
-
     def remove(self):
         self.task.remove_annotation(self)
 
     def __unicode__(self):
         return self['description']
 
     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):
     __repr__ = __unicode__
 
 
 class Task(TaskResource):
-    read_only_fields = ['id', 'entry', 'urgency', 'uuid']
+    read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
 
     class DoesNotExist(Exception):
         pass
 
     class DoesNotExist(Exception):
         pass
@@ -100,28 +333,76 @@ class Task(TaskResource):
         """
         pass
 
         """
         pass
 
-    def __init__(self, warrior, data={}, **kwargs):
+    @classmethod
+    def from_input(cls, input_file=sys.stdin, modify=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
+        original state of the Task object, and second line as its new,
+        modified value. This is consistent with the TaskWarrior's hook
+        system.
+
+        Object created by this method should not be saved, deleted
+        or refreshed, as t could create a infinite loop. For this
+        reason, TaskWarrior instance is set to None.
+
+        Input_file argument can be used to specify the input file,
+        but defaults to sys.stdin.
+        """
+
+        # TaskWarrior instance is set to None
+        task = cls(None)
+
+        # Detect the hook type if not given directly
+        name = os.path.basename(sys.argv[0])
+        modify = name.startswith('on-modify') if modify is None else modify
+
+        # Load the data from the input
+        task._load_data(json.loads(input_file.readline().strip()))
+
+        # If this is a on-modify event, we are provided with additional
+        # line of input, which provides updated data
+        if modify:
+            task._update_data(json.loads(input_file.readline().strip()))
+
+        return task
+
+    def __init__(self, warrior, **kwargs):
         self.warrior = warrior
 
         self.warrior = warrior
 
-        # We keep data for backwards compatibility
-        kwargs.update(data)
+        # Check that user is not able to set read-only value in __init__
+        for key in kwargs.keys():
+            if key in self.read_only_fields:
+                raise RuntimeError('Field \'%s\' is read-only' % key)
+
+        # We serialize the data in kwargs so that users of the library
+        # do not have to pass different data formats via __setitem__ and
+        # __init__ methods, that would be confusing
 
 
-        self._load_data(kwargs)
+        # Rather unfortunate syntax due to python2.6 comaptiblity
+        self._data = dict((key, self._normalize(key, value))
+                          for (key, value) in six.iteritems(kwargs))
+        self._original_data = copy.deepcopy(self._data)
 
     def __unicode__(self):
         return self['description']
 
     def __eq__(self, other):
 
     def __unicode__(self):
         return self['description']
 
     def __eq__(self, other):
-        return self['uuid'] == other['uuid']
+        if self['uuid'] and other['uuid']:
+            # For saved Tasks, just define equality by equality of uuids
+            return self['uuid'] == other['uuid']
+        else:
+            # If the tasks are not saved, compare the actual instances
+            return id(self) == id(other)
 
 
-    def __hash__(self):
-        return self['uuid'].__hash__()
 
 
-    @property
-    def _modified_fields(self):
-        for key in self._data.keys():
-            if self._data.get(key) != self._original_data.get(key):
-                yield key
+    def __hash__(self):
+        if self['uuid']:
+            # For saved Tasks, just define equality by equality of uuids
+            return self['uuid'].__hash__()
+        else:
+            # If the tasks are not saved, return hash of instance id
+            return id(self).__hash__()
 
     @property
     def completed(self):
 
     @property
     def completed(self):
@@ -143,24 +424,41 @@ class Task(TaskResource):
     def saved(self):
         return self['uuid'] is not None or self['id'] is not None
 
     def saved(self):
         return self['uuid'] is not None or self['id'] is not None
 
-    def serialize_due(self, date):
-        return date.strftime(DATE_FORMAT)
+    def serialize_depends(self, cur_dependencies):
+        # Check that all the tasks are saved
+        for task in (cur_dependencies or set()):
+            if not task.saved:
+                raise Task.NotSaved('Task \'%s\' needs to be saved before '
+                                    'it can be set as dependency.' % task)
 
 
-    def deserialize_due(self, date_str):
-        if not date_str:
-            return None
-        return datetime.datetime.strptime(date_str, DATE_FORMAT)
+        return super(Task, self).serialize_depends(cur_dependencies)
 
 
-    def deserialize_annotations(self, data):
-        return [TaskAnnotation(self, d) for d in data] if data else []
+    def format_depends(self):
+        # We need to generate added and removed dependencies list,
+        # since Taskwarrior does not accept redefining dependencies.
 
 
-    def deserialize_tags(self, tags):
-        if isinstance(tags, basestring):
-            return tags.split(',') if tags else []
-        return tags
+        # 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
 
 
-    def serialize_tags(self, tags):
-        return ','.join(tags) if tags else ''
+        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 "description:'{0}'".format(self._data['description'] or '')
 
     def delete(self):
         if not self.saved:
 
     def delete(self):
         if not self.saved:
@@ -172,9 +470,7 @@ class Task(TaskResource):
         if self.deleted:
             raise Task.DeletedTask("Task was already deleted")
 
         if self.deleted:
             raise Task.DeletedTask("Task was already deleted")
 
-        self.warrior.execute_command([self['uuid'], 'delete'], config_override={
-            'confirmation': 'no',
-        })
+        self.warrior.execute_command([self['uuid'], 'delete'])
 
         # Refresh the status again, so that we have updated info stored
         self.refresh(only_fields=['status'])
 
         # Refresh the status again, so that we have updated info stored
         self.refresh(only_fields=['status'])
@@ -198,6 +494,9 @@ class Task(TaskResource):
         self.refresh(only_fields=['status'])
 
     def save(self):
         self.refresh(only_fields=['status'])
 
     def save(self):
+        if self.saved and not self.modified:
+            return
+
         args = [self['uuid'], 'modify'] if self.saved else ['add']
         args.extend(self._get_modified_fields_as_args())
         output = self.warrior.execute_command(args)
         args = [self['uuid'], 'modify'] if self.saved else ['add']
         args.extend(self._get_modified_fields_as_args())
         output = self.warrior.execute_command(args)
@@ -215,6 +514,9 @@ class Task(TaskResource):
             # Circumvent the ID storage, since ID is considered read-only
             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
 
             # Circumvent the ID storage, since ID is considered read-only
             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
 
+        # Refreshing is very important here, as not only modification time
+        # is updated, but arbitrary attribute may have changed due hooks
+        # altering the data before saving
         self.refresh()
 
     def add_annotation(self, annotation):
         self.refresh()
 
     def add_annotation(self, annotation):
@@ -227,7 +529,7 @@ class Task(TaskResource):
 
     def remove_annotation(self, annotation):
         if not self.saved:
 
     def remove_annotation(self, annotation):
         if not self.saved:
-            raise Task.NotSaved("Task needs to be saved to add annotation")
+            raise Task.NotSaved("Task needs to be saved to remove annotation")
 
         if isinstance(annotation, TaskAnnotation):
             annotation = annotation['description']
 
         if isinstance(annotation, TaskAnnotation):
             annotation = annotation['description']
@@ -239,12 +541,24 @@ class Task(TaskResource):
         args = []
 
         def add_field(field):
         args = []
 
         def add_field(field):
-            # 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 and field == 'description':
-                args.append(self._data[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:
             else:
-                args.append('{0}:{1}'.format(field, self._data[field]))
+                escaped_serialized_value = "'{0}'".format(serialized_value)
+
+            format_default = lambda: "{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:
 
         # If we're modifying saved task, simply pass on all modified fields
         if self.saved:
@@ -272,14 +586,11 @@ class Task(TaskResource):
         if only_fields:
             to_update = dict(
                 [(k, new_data.get(k)) for k in only_fields])
         if only_fields:
             to_update = dict(
                 [(k, new_data.get(k)) for k in only_fields])
-            self._data.update(to_update)
-            self._original_data.update(to_update)
+            self._update_data(to_update, update_original=True)
         else:
         else:
-            self._data = new_data
-            self._original_data = new_data
-
+            self._load_data(new_data)
 
 
-class TaskFilter(object):
+class TaskFilter(SerializingObject):
     """
     A set of parameters to filter the task list with.
     """
     """
     A set of parameters to filter the task list with.
     """
@@ -295,14 +606,26 @@ class TaskFilter(object):
 
         # Replace the value with empty string, since that is the
         # convention in TW for empty values
 
         # Replace the value with empty string, since that is the
         # convention in TW for empty values
-        value = value if value is not None else ''
+        attribute_key = key.split('.')[0]
+
+        # Since this is user input, we need to normalize before we serialize
+        value = self._normalize(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:
 
         # 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:
-            self.filter_params.append('{0}:{1}'.format(key, value))
+            # 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
+            modifier = '.is' if value else '.none'
+            key = key + modifier if '.' not in key else key
+
+            self.filter_params.append("{0}:{1}".format(key, value))
 
     def get_filter_params(self):
         return [f for f in self.filter_params if f]
 
     def get_filter_params(self):
         return [f for f in self.filter_params if f]
@@ -430,6 +753,9 @@ class TaskWarrior(object):
             os.makedirs(data_location)
         self.config = {
             'data.location': os.path.expanduser(data_location),
             os.makedirs(data_location)
         self.config = {
             'data.location': os.path.expanduser(data_location),
+            'confirmation': 'no',
+            'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
+            'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
         }
         self.tasks = TaskQuerySet(self)
         self.version = self._get_version()
         }
         self.tasks = TaskQuerySet(self)
         self.version = self._get_version()
@@ -473,7 +799,9 @@ class TaskWarrior(object):
             if line:
                 data = line.strip(',')
                 try:
             if line:
                 data = line.strip(',')
                 try:
-                    tasks.append(Task(self, json.loads(data)))
+                    filtered_task = Task(self)
+                    filtered_task._load_data(json.loads(data))
+                    tasks.append(filtered_task)
                 except ValueError:
                     raise TaskWarriorException('Invalid JSON: %s' % data)
         return tasks
                 except ValueError:
                     raise TaskWarriorException('Invalid JSON: %s' % data)
         return tasks
@@ -485,6 +813,4 @@ class TaskWarrior(object):
         })
 
     def undo(self):
         })
 
     def undo(self):
-        self.execute_command(['undo'], config_override={
-            'confirmation': 'no',
-        })
+        self.execute_command(['undo'])