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

TaskFilter: Fix typo when normalizing input
[etc/taskwarrior.git] / tasklib / task.py
index 17af8d4977eaaea69f99e2b7608b53b2851e0cbc..8a2ea7af0c480c0fab3f77633f7d380e7143e0d4 100644 (file)
@@ -19,6 +19,8 @@ VERSION_2_1_0 = six.u('2.1.0')
 VERSION_2_2_0 = six.u('2.2.0')
 VERSION_2_3_0 = six.u('2.3.0')
 VERSION_2_4_0 = six.u('2.4.0')
 VERSION_2_2_0 = six.u('2.2.0')
 VERSION_2_3_0 = six.u('2.3.0')
 VERSION_2_4_0 = six.u('2.4.0')
+VERSION_2_4_1 = six.u('2.4.1')
+VERSION_2_4_2 = six.u('2.4.2')
 
 logger = logging.getLogger(__name__)
 local_zone = tzlocal.get_localzone()
 
 logger = logging.getLogger(__name__)
 local_zone = tzlocal.get_localzone()
@@ -52,9 +54,6 @@ class ReadOnlyDictView(object):
     def get(self, key, default=None):
         return copy.deepcopy(self.viewed_dict.get(key, default))
 
     def get(self, key, default=None):
         return copy.deepcopy(self.viewed_dict.get(key, default))
 
-    def has_key(self, key):
-        return self.viewed_dict.has_key(key)
-
     def items(self):
         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
 
     def items(self):
         return [copy.deepcopy(v) for v in self.viewed_dict.items()]
 
@@ -78,6 +77,15 @@ class SerializingObject(object):
         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.
         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):
     """
 
     def _deserialize(self, key, value):
@@ -97,6 +105,10 @@ class SerializingObject(object):
         or entered as a value of Task attribute.
         """
 
         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)
 
         normalize_func = getattr(self, 'normalize_{0}'.format(key),
                                  lambda x: x)
 
@@ -139,6 +151,24 @@ class SerializingObject(object):
     def normalize_modified(self, value):
         return self.datetime_normalizer(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 serialize_due(self, value):
         return self.timestamp_serializer(value)
 
@@ -227,13 +257,14 @@ class SerializingObject(object):
             # 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
             # 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
         return localized
 
     def normalize_uuid(self, value):
         # Enforce sane UUID
-        if not isinstance(value, six.text_type) or value == '':
-            raise ValueError("UUID must be a valid non-empty string.")
+        if not isinstance(value, six.string_types) or value == '':
+            raise ValueError("UUID must be a valid non-empty string, "
+                             "not: {}".format(value))
 
         return value
 
 
         return value
 
@@ -374,7 +405,7 @@ class Task(TaskResource):
         pass
 
     @classmethod
         pass
 
     @classmethod
-    def from_input(cls, input_file=sys.stdin, modify=None):
+    def from_input(cls, input_file=sys.stdin, modify=None, warrior=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
         """
         Creates a Task object, directly from the stdin, by reading one line.
         If modify=True, two lines are used, first line interpreted as the
@@ -390,13 +421,18 @@ class Task(TaskResource):
         but defaults to sys.stdin.
         """
 
         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
 
         # 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
 
+        # Create the TaskWarrior instance if none passed
+        if warrior is None:
+            hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
+            warrior = TaskWarrior(data_location=hook_parent_dir)
+
+        # TaskWarrior instance is set to None
+        task = cls(warrior)
+
         # Load the data from the input
         task._load_data(json.loads(input_file.readline().strip()))
 
         # Load the data from the input
         task._load_data(json.loads(input_file.readline().strip()))
 
@@ -516,8 +552,24 @@ 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(only_fields=['status', 'start', 'end'])
+
+    def start(self):
+        if not self.saved:
+            raise Task.NotSaved("Task needs to be saved before it can be started")
+
+        # Refresh, and raise exception if task is already completed/deleted
         self.refresh(only_fields=['status'])
 
         self.refresh(only_fields=['status'])
 
+        if self.completed:
+            raise Task.CompletedTask("Cannot start a completed task")
+        elif self.deleted:
+            raise Task.DeletedTask("Deleted task cannot be started")
+
+        self.warrior.execute_command([self['uuid'], 'start'])
+
+        # Refresh the status again, so that we have updated info stored
+        self.refresh(only_fields=['status', 'start'])
 
     def done(self):
         if not self.saved:
 
     def done(self):
         if not self.saved:
@@ -534,7 +586,7 @@ 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(only_fields=['status'])
+        self.refresh(only_fields=['status', 'start', 'end'])
 
     def save(self):
         if self.saved and not self.modified:
 
     def save(self):
         if self.saved and not self.modified:
@@ -652,7 +704,7 @@ class TaskFilter(SerializingObject):
         attribute_key = key.split('.')[0]
 
         # Since this is user input, we need to normalize before we serialize
         attribute_key = key.split('.')[0]
 
         # Since this is user input, we need to normalize before we serialize
-        value = self._normalize(key, value)
+        value = self._normalize(attribute_key, value)
         value = self._serialize(attribute_key, value)
 
         # If we are filtering by uuid:, do not use uuid keyword
         value = self._serialize(attribute_key, value)
 
         # If we are filtering by uuid:, do not use uuid keyword
@@ -829,7 +881,7 @@ class TaskWarrior(object):
         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
         if p.returncode and allow_failure:
             if stderr.strip():
         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
         if p.returncode and allow_failure:
             if stderr.strip():
-                error_msg = stderr.strip().splitlines()[-1]
+                error_msg = stderr.strip()
             else:
                 error_msg = stdout.strip()
             raise TaskWarriorException(error_msg)
             else:
                 error_msg = stdout.strip()
             raise TaskWarriorException(error_msg)
@@ -838,9 +890,10 @@ class TaskWarrior(object):
     def enforce_recurrence(self):
         # Run arbitrary report command which will trigger generation
         # of recurrent tasks.
     def enforce_recurrence(self):
         # Run arbitrary report command which will trigger generation
         # of recurrent tasks.
-        # TODO: Make a version dependant enforcement once
-        #       TW-1531 is handled
-        self.execute_command(['next'], allow_failure=False)
+
+        # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
+        if self.version < VERSION_2_4_2:
+            self.execute_command(['next'], allow_failure=False)
 
     def filter_tasks(self, filter_obj):
         self.enforce_recurrence()
 
     def filter_tasks(self, filter_obj):
         self.enforce_recurrence()