]> 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:

Bump version for 0.8.0
[etc/taskwarrior.git] / tasklib / task.py
index 9d6d47f4a8bcf6c0951514d9c0a78f6056182ed7..9b3626dec070cd741b194900da813f6ae9e84db9 100644 (file)
@@ -5,6 +5,7 @@ import json
 import logging
 import os
 import six
 import logging
 import os
 import six
+import sys
 import subprocess
 
 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
 import subprocess
 
 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
@@ -28,6 +29,18 @@ class SerializingObject(object):
     """
     Common ancestor for TaskResource & TaskFilter, since they both
     need to serialize arguments.
     """
     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):
     """
 
     def _deserialize(self, key, value):
@@ -42,7 +55,7 @@ class SerializingObject(object):
 
     def timestamp_serializer(self, date):
         if not date:
 
     def timestamp_serializer(self, date):
         if not date:
-            return None
+            return ''
         return date.strftime(DATE_FORMAT)
 
     def timestamp_deserializer(self, date_str):
         return date.strftime(DATE_FORMAT)
 
     def timestamp_deserializer(self, date_str):
@@ -97,9 +110,10 @@ class SerializingObject(object):
             return tags.split(',') if tags else []
         return tags or []
 
             return tags.split(',') if tags else []
         return tags or []
 
-    def serialize_depends(self, cur_dependencies):
+    def serialize_depends(self, value):
         # Return the list of uuids
         # Return the list of uuids
-        return ','.join(task['uuid'] for task in cur_dependencies)
+        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
 
     def deserialize_depends(self, raw_uuids):
         raw_uuids = raw_uuids or ''  # Convert None to empty string
@@ -117,6 +131,19 @@ class TaskResource(SerializingObject):
         # are not propagated.
         self._original_data = copy.deepcopy(self._data)
 
         # 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
     def __getitem__(self, key):
         # This is a workaround to make TaskResource non-iterable
         # over simple index-based iteration
@@ -159,6 +186,11 @@ class TaskAnnotation(TaskResource):
     def __unicode__(self):
         return self['description']
 
     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__
 
 
     __repr__ = __unicode__
 
 
@@ -187,6 +219,40 @@ class Task(TaskResource):
         """
         pass
 
         """
         pass
 
+    @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
 
     def __init__(self, warrior, **kwargs):
         self.warrior = warrior
 
@@ -227,11 +293,19 @@ class Task(TaskResource):
     def _modified_fields(self):
         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
         for key in writable_fields:
     def _modified_fields(self):
         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
         for key in writable_fields:
-            if self._data.get(key) != self._original_data.get(key):
+            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
                 yield key
 
     @property
-    def _is_modified(self):
+    def modified(self):
         return bool(list(self._modified_fields))
 
     @property
         return bool(list(self._modified_fields))
 
     @property
@@ -256,7 +330,7 @@ class Task(TaskResource):
 
     def serialize_depends(self, cur_dependencies):
         # Check that all the tasks are saved
 
     def serialize_depends(self, cur_dependencies):
         # Check that all the tasks are saved
-        for task in cur_dependencies:
+        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)
             if not task.saved:
                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
                                     'it can be set as dependency.' % task)
@@ -295,7 +369,7 @@ class Task(TaskResource):
             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
         # Refresh the status, and raise exception if the task is deleted
             raise Task.NotSaved("Task needs to be saved before it can be deleted")
 
         # Refresh the status, and raise exception if the task is deleted
-        self.refresh()
+        self.refresh(only_fields=['status'])
 
         if self.deleted:
             raise Task.DeletedTask("Task was already deleted")
 
         if self.deleted:
             raise Task.DeletedTask("Task was already deleted")
@@ -303,7 +377,7 @@ class Task(TaskResource):
         self.warrior.execute_command([self['uuid'], 'delete'])
 
         # Refresh the status again, so that we have updated info stored
         self.warrior.execute_command([self['uuid'], 'delete'])
 
         # Refresh the status again, so that we have updated info stored
-        self.refresh()
+        self.refresh(only_fields=['status'])
 
 
     def done(self):
 
 
     def done(self):
@@ -311,7 +385,7 @@ class Task(TaskResource):
             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
         # Refresh, and raise exception if task is already completed/deleted
             raise Task.NotSaved("Task needs to be saved before it can be completed")
 
         # Refresh, and raise exception if task is already completed/deleted
-        self.refresh()
+        self.refresh(only_fields=['status'])
 
         if self.completed:
             raise Task.CompletedTask("Cannot complete a completed task")
 
         if self.completed:
             raise Task.CompletedTask("Cannot complete a completed task")
@@ -321,10 +395,10 @@ class Task(TaskResource):
         self.warrior.execute_command([self['uuid'], 'done'])
 
         # Refresh the status again, so that we have updated info stored
         self.warrior.execute_command([self['uuid'], 'done'])
 
         # Refresh the status again, so that we have updated info stored
-        self.refresh()
+        self.refresh(only_fields=['status'])
 
     def save(self):
 
     def save(self):
-        if self.saved and not self._is_modified:
+        if self.saved and not self.modified:
             return
 
         args = [self['uuid'], 'modify'] if self.saved else ['add']
             return
 
         args = [self['uuid'], 'modify'] if self.saved else ['add']
@@ -344,6 +418,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):
@@ -352,7 +429,7 @@ class Task(TaskResource):
 
         args = [self['uuid'], 'annotate', annotation]
         self.warrior.execute_command(args)
 
         args = [self['uuid'], 'annotate', annotation]
         self.warrior.execute_command(args)
-        self.refresh()
+        self.refresh(only_fields=['annotations'])
 
     def remove_annotation(self, annotation):
         if not self.saved:
 
     def remove_annotation(self, annotation):
         if not self.saved:
@@ -362,7 +439,7 @@ class Task(TaskResource):
             annotation = annotation['description']
         args = [self['uuid'], 'denotate', annotation]
         self.warrior.execute_command(args)
             annotation = annotation['description']
         args = [self['uuid'], 'denotate', annotation]
         self.warrior.execute_command(args)
-        self.refresh()
+        self.refresh(only_fields=['annotations'])
 
     def _get_modified_fields_as_args(self):
         args = []
 
     def _get_modified_fields_as_args(self):
         args = []
@@ -370,13 +447,21 @@ class Task(TaskResource):
         def add_field(field):
             # Add the output of format_field method to args list (defaults to
             # field:value)
         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]) or ''
-            format_default = lambda: "{0}:{1}".format(
-                field,
-                "'{0}'".format(serialized_value) if serialized_value else ''
-            )
+            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 = "'{0}'".format(serialized_value)
+
+            format_default = lambda: "{0}:{1}".format(field,
+                                                      escaped_serialized_value)
+
             format_func = getattr(self, 'format_{0}'.format(field),
                                   format_default)
             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
             args.append(format_func())
 
         # If we're modifying saved task, simply pass on all modified fields
@@ -392,7 +477,7 @@ class Task(TaskResource):
 
         return args
 
 
         return args
 
-    def refresh(self):
+    def refresh(self, only_fields=[]):
         # 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")
         # 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")
@@ -402,8 +487,27 @@ class Task(TaskResource):
         # with using UUID only.
         args = [self['uuid'] or self['id'], 'export']
         new_data = json.loads(self.warrior.execute_command(args)[0])
         # with using UUID only.
         args = [self['uuid'] or self['id'], 'export']
         new_data = json.loads(self.warrior.execute_command(args)[0])
-        self._load_data(new_data)
+        if only_fields:
+            to_update = dict(
+                [(k, new_data.get(k)) for k in only_fields])
+            self._update_data(to_update, update_original=True)
+        else:
+            self._load_data(new_data)
+
+    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=(',',':'))
 
 class TaskFilter(SerializingObject):
     """
 
 class TaskFilter(SerializingObject):
     """