diff --git a/CHANGES.txt b/CHANGES.txt index 7b8af7b..48f24da 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,6 +1,13 @@ Changes ======= +Next release +------------ + +- Add ``colander.DateTime`` and ``colander.Date`` data types. + +- Depend on the ``iso8601`` package for date support. + 0.3 (2010-03-29) ---------------- diff --git a/colander/__init__.py b/colander/__init__.py index 821a441..fde8ab9 100644 --- a/colander/__init__.py +++ b/colander/__init__.py @@ -1,4 +1,6 @@ +import datetime import itertools +import iso8601 import pprint class _missing(object): @@ -580,6 +582,102 @@ class GlobalObject(object): except AttributeError: raise Invalid(node, '%r has no __name__' % value) +class DateTime(object): + """ A type representing a Python ``datetime.datetime`` object. + + This type serializes python ``datetime.datetime`` objects to a + `ISO8601 `_ string format. + The format includes the date, the time, and the timezone of the + datetime. + + The constructor accepts a single argument named ``default_tzinfo`` + which should be a Python ``tzinfo`` object; by default it is None, + meaning that the default tzinfo will be equivalent to UTC (Zulu + time). The ``default_tzinfo`` tzinfo object is used to convert + 'naive' datetimes to a timezone-aware representation during + serialization. + + For convenience, this type is also willing to coerce + ``datetime.date`` objects to a DateTime ISO string representation + during serialization. It does so by using midnight of the day as + the time, and uses the ``default_tzinfo`` to give the + serialization a timezone. + + This type can only convert ISO8601 values that include a date, a + time, and a timezone (or ``Z``) in their representations. It will + fail to parse date-only ISO8601 representations. + + The subnodes of the :class:`colander.SchemaNode` that wraps + this type are ignored. + """ + def __init__(self, default_tzinfo=None): + if default_tzinfo is None: + default_tzinfo = iso8601.iso8601.Utc() + self.default_tzinfo = default_tzinfo + + def serialize(self, node, value): + if type(value) is datetime.date: # cant use isinstance; dt subs date + value = datetime.datetime.combine(value, datetime.time()) + if not isinstance(value, datetime.datetime): + raise Invalid(node, '%r is not a datetime object' % value) + if value.tzinfo is None: + value = value.replace(tzinfo=self.default_tzinfo) + return value.isoformat() + + def deserialize(self, node, value): + try: + result = iso8601.parse_date(value) + except (iso8601.ParseError, TypeError), e: + raise Invalid(node, + '%s cannot be parsed as an iso8601 datetime: %s' % + (value, e)) + return result + +class Date(object): + """ A type representing a Python ``datetime.date`` object. + + This type serializes python ``datetime.date`` objects to a + `ISO8601 `_ string format. + The format includes the date only. + + The constructor accepts no arguments. + + For convenience, this type is also willing to coerce + ``datetime.datetime`` objects to a date-only ISO string + representation during serialization. It does so by stripping off + any time information, converting the ``datetime.datetime`` into a + date before serializing. + + Likewise, for convenience, this type is also willing to coerce ISO + representations that contain time info into a ``datetime.date`` + object during deserialization. It does so by throwing away any + time information related to the serialized value during + deserialization. + + The subnodes of the :class:`colander.SchemaNode` that wraps + this type are ignored. + """ + def serialize(self, node, value): + if isinstance(value, datetime.datetime): + value = value.date() + if not isinstance(value, datetime.date): + raise Invalid(node, '%r is not a date object' % value) + return value.isoformat() + + def deserialize(self, node, value): + try: + result = iso8601.parse_date(value) + result = result.date() + except (iso8601.ParseError, TypeError): + try: + year, month, day = map(int, value.split('-', 3)) + result = datetime.date(year, month, day) + except Exception, e: + raise Invalid(node, + '%s cannot be parsed as an iso8601 date: %s' % + (value, e)) + return result + class SchemaNode(object): """ Fundamental building block of schemas. diff --git a/colander/tests.py b/colander/tests.py index afb9d3d..3255698 100644 --- a/colander/tests.py +++ b/colander/tests.py @@ -899,6 +899,146 @@ class TestGlobalObject(unittest.TestCase): e = invalid_exc(typ.serialize, None, None) self.assertEqual(e.msg, 'None has no __name__') +class TestDateTime(unittest.TestCase): + def _makeOne(self, *arg, **kw): + from colander import DateTime + return DateTime(*arg, **kw) + + def test_ctor_default_tzinfo_None(self): + import iso8601 + typ = self._makeOne() + self.assertEqual(typ.default_tzinfo.__class__, iso8601.iso8601.Utc) + + def test_ctor_default_tzinfo_non_None(self): + import iso8601 + tzinfo = iso8601.iso8601.FixedOffset(1, 0, 'myname') + typ = self._makeOne(default_tzinfo=tzinfo) + self.assertEqual(typ.default_tzinfo, tzinfo) + + def test_serialize_with_garbage(self): + typ = self._makeOne() + node = DummySchemaNode(None) + e = invalid_exc(typ.serialize, node, 'garbage') + self.assertEqual(e.msg, "'garbage' is not a datetime object") + + def test_serialize_with_date(self): + import datetime + typ = self._makeOne() + date = datetime.date.today() + node = DummySchemaNode(None) + result = typ.serialize(node, date) + expected = datetime.datetime.combine(date, datetime.time()) + expected = expected.replace(tzinfo=typ.default_tzinfo).isoformat() + self.assertEqual(result, expected) + + def test_serialize_with_naive_datetime(self): + import datetime + typ = self._makeOne() + dt = datetime.datetime.now() + node = DummySchemaNode(None) + result = typ.serialize(node, dt) + expected = dt.replace(tzinfo=typ.default_tzinfo).isoformat() + self.assertEqual(result, expected) + + def test_serialize_with_tzware_datetime(self): + import datetime + import iso8601 + typ = self._makeOne() + dt = datetime.datetime.now() + tzinfo = iso8601.iso8601.FixedOffset(1, 0, 'myname') + dt = dt.replace(tzinfo=tzinfo) + node = DummySchemaNode(None) + result = typ.serialize(node, dt) + expected = dt.isoformat() + self.assertEqual(result, expected) + + def test_deserialize_invalid_TypeError(self): + import datetime + # cant parse dates without times + date = datetime.date.today() + typ = self._makeOne() + formatted = date.isoformat() + node = DummySchemaNode(None) + e = invalid_exc(typ.deserialize, node, formatted) + self.failUnless('cannot be parsed' in e.msg) + + def test_deserialize_invalid_ParseError(self): + node = DummySchemaNode(None) + typ = self._makeOne() + e = invalid_exc(typ.deserialize, node, 'garbage') + self.failUnless('Unable to parse' in e.msg) + + def test_deserialize_success(self): + import datetime + import iso8601 + typ = self._makeOne() + dt = datetime.datetime.now() + tzinfo = iso8601.iso8601.FixedOffset(1, 0, 'myname') + dt = dt.replace(tzinfo=tzinfo) + iso = dt.isoformat() + node = DummySchemaNode(None) + result = typ.deserialize(node, iso) + self.assertEqual(result.isoformat(), iso) + +class TestDate(unittest.TestCase): + def _makeOne(self, *arg, **kw): + from colander import Date + return Date(*arg, **kw) + + def test_serialize_with_garbage(self): + typ = self._makeOne() + node = DummySchemaNode(None) + e = invalid_exc(typ.serialize, node, 'garbage') + self.assertEqual(e.msg, "'garbage' is not a date object") + + def test_serialize_with_date(self): + import datetime + typ = self._makeOne() + date = datetime.date.today() + node = DummySchemaNode(None) + result = typ.serialize(node, date) + expected = date.isoformat() + self.assertEqual(result, expected) + + def test_serialize_with_datetime(self): + import datetime + typ = self._makeOne() + dt = datetime.datetime.now() + node = DummySchemaNode(None) + result = typ.serialize(node, dt) + expected = dt.date().isoformat() + self.assertEqual(result, expected) + + def test_deserialize_invalid_ParseError(self): + node = DummySchemaNode(None) + typ = self._makeOne() + e = invalid_exc(typ.deserialize, node, 'garbage') + self.failUnless('cannot be parsed' in e.msg) + + def test_deserialize_invalid_weird(self): + node = DummySchemaNode(None) + typ = self._makeOne() + e = invalid_exc(typ.deserialize, node, '10-10-10-10') + self.failUnless('cannot be parsed' in e.msg) + + def test_deserialize_success_date(self): + import datetime + typ = self._makeOne() + date = datetime.date.today() + iso = date.isoformat() + node = DummySchemaNode(None) + result = typ.deserialize(node, iso) + self.assertEqual(result.isoformat(), iso) + + def test_deserialize_success_datetime(self): + import datetime + dt = datetime.datetime.now() + typ = self._makeOne() + iso = dt.isoformat() + node = DummySchemaNode(None) + result = typ.deserialize(node, iso) + self.assertEqual(result.isoformat(), dt.date().isoformat()) + class TestSchemaNode(unittest.TestCase): def _makeOne(self, *arg, **kw): from colander import SchemaNode diff --git a/docs/api.rst b/docs/api.rst index 344d8c8..9638de8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -47,6 +47,10 @@ Types .. autoclass:: GlobalObject + .. autoclass:: DateTime + + .. autoclass:: Date + Schema-Related ~~~~~~~~~~~~~~ diff --git a/setup.py b/setup.py index 811deb1..6667458 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ here = os.path.abspath(os.path.dirname(__file__)) README = open(os.path.join(here, 'README.txt')).read() CHANGES = open(os.path.join(here, 'CHANGES.txt')).read() -requires = [] +requires = ['iso8601'] setup(name='colander', version='0.3',