Source code for nti.externalization.datetime_ext

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Support for reading, writing and converting date and time related
objects.

See the :mod:`datetime` module, as well as the
:mod:`zope.interface.common.idatetime` module for types of objects.

These are generally meant to be used as zope.interface adapters once
this package has been configured, but they can be called manually as well.
"""

# stdlib imports
from datetime import datetime
import time

import isodate
import pytz

from zope import component
from zope import interface
from zope.interface.common.idatetime import IDate
from zope.interface.common.idatetime import IDateTime
from zope.interface.common.idatetime import ITimeDelta
from zope.schema.interfaces import InvalidValue

from nti.externalization.interfaces import IInternalObjectExternalizer


__all__ = [
    # IDate
    'date_to_string',
    'date_from_string',

    # IDateTime
    'datetime_to_string',
    'datetime_from_string',
    'datetime_from_timestamp',

    # ITimeDelta
    'duration_to_string',
    'duration_from_string',
]

def _parse_with(func, string):
    try:
        return func(string)
    except isodate.ISO8601Error as e:
        raise InvalidValue(*e.args).with_field_and_value(
            None, string
        ).with_traceback(e.__traceback__) from e


_input_type = str
# XXX: This should really be either unicode or str on Python 2. We need to *know*
# what our input type is. All the tests pass on Python 3 with this registered to 'str',
# so we're never passing ``bytes``
[docs] @component.adapter(_input_type) @interface.implementer(IDate) def date_from_string(string): """ This adapter allows any field which comes in as a string in IOS8601 format to be transformed into a date. The schema field must be an `zope.schema.Object` field with a type of `zope.interface.common.idatetime.IDate`. If you need a schema field that accepts human input, rather than programattic input, you probably want to use a custom field that uses :func:`zope.datetime.parse` in its ``fromUnicode`` method. >>> from nti.externalization.datetime import date_from_string >>> date_from_string('1982-01-31') datetime.date(1982, 1, 31) """ # This: # datetime.date.fromtimestamp( zope.datetime.time( string ) ) # is simple, but seems to have confusing results, depending on what the # timezone is? If we put in "1982-01-31" we get back <1982-01-30> # This: # parsed = zope.datetime.parse( string ) # return datetime.date( parsed[0], parsed[1], parsed[2] ) # accepts almost anything as a date (so it's great for human interfaces), # but programatically we actually require ISO format return _parse_with(isodate.parse_date, string)
def _pytz_timezone(key): try: return pytz.timezone(key) except (KeyError, AttributeError): return None def _local_tzinfo(local_tzname=None): # They did not specify a timezone, assume they authored # in the native timezone, meaning to use any DST rules in # effect at the time specified, not the current time. local_tzname = local_tzname or time.tzname tzinfo = _pytz_timezone(local_tzname) # Ok, not a value known to pytz. Is it a two-tuple like ('CST', 'CDT') # that we can figure out the offset of ourself? if (not tzinfo and isinstance(local_tzname, tuple) and len(local_tzname) == 2 and all((bool(x) for x in local_tzname))): offset_hours = time.timezone // 3600 local_tzname = '%s%d%s' % (local_tzname[0], offset_hours, local_tzname[1]) tzinfo = _pytz_timezone(local_tzname) if not tzinfo: # well nuts. Do the best we can with the current info # First, get the timezone name, using daylight name if appropriate offset = (time.altzone if time.daylight and time.altzone is not None and time.tzname[1] else time.timezone) add = '+' if offset > 0 else '' local_tzname = 'Etc/GMT' + add + str((offset // 60 // 60)) # pylint:disable=redefined-variable-type tzinfo = pytz.timezone(local_tzname) return tzinfo def _as_utc_naive(dt, assume_local=True, local_tzname=None): # Now convert to GMT, but as a 'naive' object. if not dt.tzinfo: if assume_local: tzinfo = _local_tzinfo(local_tzname) dt = tzinfo.localize(dt) else: dt = dt.replace(tzinfo=pytz.UTC) # Convert to UTC, then back to naive dt = dt.astimezone(pytz.UTC).replace(tzinfo=None) return dt
[docs] @component.adapter(_input_type) @interface.implementer(IDateTime) def datetime_from_string(string, assume_local=False, local_tzname=None): """ This adapter allows any field which comes in as a string in IOS8601 format to be transformed into a :class:`datetime.datetime`. The schema field should be an `nti.schema.field.Object` field with a type of `zope.interface.common.idatetime.IDateTime` or an instance of `nti.schema.field.ValidDateTime`. Wrap this with an :class:`nti.schema.fieldproperty.AdaptingFieldProperty`. Datetime values produced by this object will always be in GMT/UTC time, and they will always be datetime naive objects. If you need a schema field that accepts human input, rather than programattic input, you probably want to use a custom field that uses :func:`zope.datetime.parse` in its ``fromUnicode`` method. When used as an adapter, no parameters are accepted. >>> from zope.interface.common.idatetime import IDateTime >>> from zope.component import provideAdapter >>> from nti.externalization.datetime import datetime_from_string >>> provideAdapter(datetime_from_string) >>> IDateTime('1982-01-31T00:00:00Z') datetime.datetime(1982, 1, 31, 0, 0) :param bool assume_local: If `False`, the default, then when we parse a string that does not include timezone information, we will assume that it is already meant to be in UTC. Otherwise, if set to true, when we parse such a string we will assume that it is meant to be in the \"local\" timezone and adjust accordingly. If the local timezone experiences DST, then the time will be interpreted with the UTC offset *as-of the DST rule in effect on the date parsed*, not the current date, if possible. If not possible, the current rule will be used. :param str local_tzname: If given, either a string acceptable to :func:`pytz.timezone` to produce a ``tzinfo`` object, or a two-tuple as given from :const:`time.timezone`. If not given, local timezone will be determined automatically. """ dt = _parse_with(isodate.parse_datetime, string) return _as_utc_naive(dt, assume_local=assume_local, local_tzname=local_tzname)
[docs] @component.adapter(int) @interface.implementer(IDateTime) def datetime_from_timestamp(value): """ Produce a :class:`datetime.datetime` from a UTC timestamp. This is a registered adapter for both integers and floats. >>> from zope.interface.common.idatetime import IDateTime >>> from zope.component import provideAdapter >>> from nti.externalization.datetime import datetime_from_timestamp >>> provideAdapter(datetime_from_timestamp, (int,)) >>> provideAdapter(datetime_from_timestamp, (float,)) >>> IDateTime(123456) datetime.datetime(1970, 1, 2, 10, 17, 36, tzinfo=<UTC>) >>> IDateTime(654321.0) datetime.datetime(1970, 1, 8, 13, 45, 21, tzinfo=<UTC>) .. versionchanged:: 3.0.0 The returned objects now have the UTC timezone set. Previously they were naive, but that is deprecated. """ return datetime.fromtimestamp(value, pytz.UTC)
[docs] @component.adapter(IDate) @interface.implementer(IInternalObjectExternalizer) class date_to_string(object): """ Produce an IOS8601 string from a date. Registered as an adapter from `zope.interface.common.idatetime.IDate` to `~nti.externalization.interfaces.IInternalObjectExternalizer`. >>> import datetime >>> from nti.externalization.externalization import to_external_object >>> from nti.externalization.datetime import date_to_string >>> from zope import component >>> component.provideAdapter(date_to_string) >>> to_external_object(datetime.date(1982, 1, 31)) '1982-01-31' """ def __init__(self, date): self.date = date def toExternalObject(self, **unused_kwargs): return isodate.date_isoformat(self.date)
[docs] @component.adapter(IDateTime) @interface.implementer(IInternalObjectExternalizer) class datetime_to_string(object): """ Produce an IOS8601 string from a datetime. Registered as an adapter from `zope.interface.common.idatetime.IDateTime` to `~nti.externalization.interfaces.IInternalObjectExternalizer`. >>> from zope.component import provideAdapter >>> import datetime >>> from nti.externalization import to_external_object >>> from nti.externalization.datetime import datetime_to_string >>> provideAdapter(datetime_to_string) >>> to_external_object(datetime.datetime(1982, 1, 31)) '1982-01-31T00:00:00Z' """ def __init__(self, date): self.date = date def toExternalObject(self, **unused_kwargs): # Convert to UTC, assuming that a missing timezone # is already in UTC dt = _as_utc_naive(self.date, assume_local=False) # indicate it is UTC on the wire return isodate.datetime_isoformat(dt) + 'Z'
[docs] @component.adapter(ITimeDelta) @interface.implementer(IInternalObjectExternalizer) class duration_to_string(object): """ Produce an IOS8601 format duration from a :class:`datetime.timedelta` object. Timedelta objects do not represent years or months (the biggest duration they accept is weeks) and internally they normalize everything to days and smaller. Thus, the format produced by this transformation will never have a field larger than days. Registered as an adapter from `zope.interface.common.idatetime.ITimeDelta` to `~nti.externalization.interfaces.IInternalObjectExternalizer`. >>> import datetime >>> from zope.component import provideAdapter >>> from nti.externalization import to_external_object >>> from nti.externalization.datetime import duration_to_string >>> provideAdapter(duration_to_string) >>> to_external_object(datetime.timedelta(weeks=16)) 'P112D' """ def __init__(self, date): self.date = date def toExternalObject(self, **unused_kwargs): return isodate.duration_isoformat(self.date)
[docs] @component.adapter(_input_type) @interface.implementer(ITimeDelta) def duration_from_string(value): """ Produce a :class:`datetime.timedelta` from a ISO8601 format duration string. >>> from zope.interface.common.idatetime import ITimeDelta >>> from zope.component import provideAdapter >>> from nti.externalization.datetime import duration_from_string >>> provideAdapter(duration_from_string) >>> ITimeDelta('P0D') datetime.timedelta(0) """ return isodate.parse_duration(value)