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

Tests: Add tests for timezone aware datetimes
[etc/taskwarrior.git] / tasklib / task.py
index a273815e72befb208a6c144dbe3d706c64f24cbd..73280b0cf447bb6e1f1f246914ac0916ae2130ec 100644 (file)
@@ -4,9 +4,11 @@ import datetime
 import json
 import logging
 import os
 import json
 import logging
 import os
+import pytz
 import six
 import sys
 import subprocess
 import six
 import sys
 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
@@ -19,6 +21,7 @@ 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):
 
 
 class TaskWarriorException(Exception):
@@ -53,15 +56,36 @@ class SerializingObject(object):
                                  lambda x: x if x is not None else '')
         return dehydrate_func(value)
 
                                  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 ''
     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 date.strftime(DATE_FORMAT)
 
     def timestamp_deserializer(self, date_str):
         if not date_str:
             return None
-        return datetime.datetime.strptime(date_str, DATE_FORMAT)
+
+        # 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 serialize_entry(self, value):
         return self.timestamp_serializer(value)
@@ -69,36 +93,54 @@ class SerializingObject(object):
     def deserialize_entry(self, value):
         return self.timestamp_deserializer(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 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 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 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 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 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 []
 
     def serialize_annotations(self, value):
         value = value if value is not None else []
 
@@ -129,6 +171,32 @@ class SerializingObject(object):
         uuids = raw_uuids.split(',')
         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
 
         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 = []
 
 class TaskResource(SerializingObject):
     read_only_fields = []
@@ -170,6 +238,9 @@ class TaskResource(SerializingObject):
     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)
+
+        # Normalize the user input before saving it
+        value = self._normalize(key, value)
         self._data[key] = value
 
     def __str__(self):
         self._data[key] = value
 
     def __str__(self):
@@ -309,8 +380,9 @@ class Task(TaskResource):
         # __init__ methods, that would be confusing
 
         # Rather unfortunate syntax due to python2.6 comaptiblity
         # __init__ methods, that would be confusing
 
         # Rather unfortunate syntax due to python2.6 comaptiblity
-        self._load_data(dict((key, self._serialize(key, value))
-                        for (key, value) in six.iteritems(kwargs)))
+        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 __unicode__(self):
         return self['description']
@@ -535,6 +607,9 @@ class TaskFilter(SerializingObject):
         # Replace the value with empty string, since that is the
         # convention in TW for empty values
         attribute_key = key.split('.')[0]
         # 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(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