# -*- coding: utf-8 -*-
# Copyright (c) 2013-2014 NASK. All rights reserved.
#
# For some parts of the source code of the FixedOffsetTimezone class:
# Copyright (c) 2001-2014 Python Software Foundation. All rights reserved.
# (For more information -- see the FixedOffsetTimezone's docstring and
# the https://docs.python.org/2.7/license.html web page.)
import calendar
import datetime
from n6sdk.regexes import (
ISO_DATE_REGEX,
ISO_TIME_REGEX,
ISO_DATETIME_REGEX,
)
[docs]class FixedOffsetTimezone(datetime.tzinfo):
"""
TZ-info to represent fixed offset in minutes east from UTC.
The source code of the class has been copied from
http://docs.python.org/2.7/library/datetime.html#tzinfo-objects,
then adjusted, enriched and documented.
>>> tz = FixedOffsetTimezone(180)
>>> tz
FixedOffsetTimezone(180)
>>> dt = datetime.datetime(2014, 5, 31, 1, 2, 3, tzinfo=tz)
>>> dt.utcoffset()
datetime.timedelta(0, 10800)
>>> dt.dst()
datetime.timedelta(0)
>>> dt.tzname()
'<UTC Offset: +180>'
>>> dt.astimezone(FixedOffsetTimezone(-60))
datetime.datetime(2014, 5, 30, 21, 2, 3, tzinfo=FixedOffsetTimezone(-60))
"""
def __init__(self, offset):
self.__ZERO = datetime.timedelta(0)
self.__offset = offset
self.__td_offset = datetime.timedelta(minutes=offset)
self.__name = '<UTC Offset: {0:+04}>'.format(offset)
def __repr__(self):
return '{0}({1!r})'.format(self.__class__.__name__,
self.__offset)
[docs] def utcoffset(self, dt):
return self.__td_offset
[docs] def tzname(self, dt):
return self.__name
[docs] def dst(self, dt):
return self.__ZERO
[docs]def datetime_to_utc_timestamp(dt):
"""
Convert a :class:`datetime.datetime` to a UTC timestamp.
Args:
`dt`: A :class:`datetime.datetime` instance (naive or TZ-aware).
Returns:
The equivalent timestamp as a :class:`float` number.
>>> naive_dt = datetime.datetime(2013, 6, 6, 12, 13, 57, 251211)
>>> t = datetime_to_utc_timestamp(naive_dt)
>>> t
1370520837.251211
>>> datetime.datetime.utcfromtimestamp(t)
datetime.datetime(2013, 6, 6, 12, 13, 57, 251211)
>>> tzinfo = FixedOffsetTimezone(120)
>>> tz_aware_dt = datetime.datetime(2013, 6, 6, 14, 13, 57, 251211,
... tzinfo=tzinfo)
>>> t2 = datetime_to_utc_timestamp(tz_aware_dt)
>>> t2 == t
True
>>> utc_naive_dt = datetime.datetime.utcfromtimestamp(t2)
>>> utc_tzinfo = FixedOffsetTimezone(0) # just UTC
>>> utc_tz_aware_dt = utc_naive_dt.replace(tzinfo=utc_tzinfo)
>>> utc_tz_aware_dt.hour
12
>>> tz_aware_dt.hour
14
>>> utc_tz_aware_dt == tz_aware_dt
True
"""
tt = dt.utctimetuple()
fractional_part = dt.microsecond / 1000000.0
return calendar.timegm(tt) + fractional_part
[docs]def datetime_utc_normalize(dt):
"""
Normalize a :class:`datetime.datetime` to a naive UTC one.
Args:
`dt`: A :class:`datetime.datetime` instance (naive or TZ-aware).
Returns:
An equivalent :class:`datetime.datetime` instance (a naive one).
>>> naive_dt = datetime.datetime(2013, 6, 6, 12, 13, 57, 251211)
>>> datetime_utc_normalize(naive_dt)
datetime.datetime(2013, 6, 6, 12, 13, 57, 251211)
>>> tzinfo = FixedOffsetTimezone(120)
>>> tz_aware_dt = datetime.datetime(2013, 6, 6, 14, 13, 57, 251211,
... tzinfo=tzinfo)
>>> datetime_utc_normalize(tz_aware_dt)
datetime.datetime(2013, 6, 6, 12, 13, 57, 251211)
"""
return datetime.datetime.utcfromtimestamp(datetime_to_utc_timestamp(dt))
# TODO -- better tests:
# * translate doctests into unittests
# * add more corner cases
[docs]def parse_iso_date(s, prestrip=True):
"""
Parse *ISO-8601*-formatted date.
Args:
`s`: *ISO-8601*-formatted date as a string.
Kwargs:
`prestrip` (default: :obj:`True`):
Whether the :meth:`strip` method should be called on the
input string before performing the actual processing.
Returns:
A :class:`datetime.date` instance.
Raises:
:exc:`~exceptions.ValueError` for invalid input.
Intentional limitation: specified date must include unambiguous day
specification (inputs such as ``'2013-05'`` or ``'2013'`` are not
supported).
>>> parse_iso_date('2013-06-12')
datetime.date(2013, 6, 12)
>>> parse_iso_date('99991231')
datetime.date(9999, 12, 31)
>>> parse_iso_date('2013-W24-3')
datetime.date(2013, 6, 12)
>>> datetime.date(2013, 6, 12).isocalendar() # checking this was OK...
(2013, 24, 3)
>>> parse_iso_date('2013-W01-1')
datetime.date(2012, 12, 31)
>>> datetime.date(2012, 12, 31).isocalendar() # checking this was OK...
(2013, 1, 1)
>>> parse_iso_date('2011-W52-7')
datetime.date(2012, 1, 1)
>>> datetime.date(2012, 1, 1).isocalendar() # checking this was OK...
(2011, 52, 7)
>>> parse_iso_date('2013-001')
datetime.date(2013, 1, 1)
>>> parse_iso_date('2013-365')
datetime.date(2013, 12, 31)
>>> parse_iso_date('2012-366') # 2012 was a leap year
datetime.date(2012, 12, 31)
>>> parse_iso_date('0000-01-01') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('13-01-01') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('01-01-2013') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-6-01') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-02-31') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-W54-1') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-W22-8') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-W1-1') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-W01-01') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-000') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-366') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
>>> parse_iso_date('2013-1') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
"""
if prestrip:
s = s.strip()
match = ISO_DATE_REGEX.match(s)
if match:
return _make_date_from_match(match)
raise ValueError('could not parse {!r} as ISO date'.format(s))
# TODO: tests
[docs]def parse_iso_time(s, prestrip=True):
"""
Parse *ISO-8601*-formatted time.
Args:
`s`: *ISO-8601*-formatted time as a string.
Kwargs:
`prestrip` (default: :obj:`True`):
Whether the strip() method should be called on the input string
before performing the actual processing.
Returns:
A :class:`datetime.time` instance (a TZ-aware one if the input
does include time zone information, otherwise a naive one).
Raises:
:exc:`exceptions.ValueError` for invalid input.
Intentional limitation: specified time must include at least hour and
minute. Second, microsecond and timezone information are optional.
*ISO-8601*-enabled "leap second" (60) is accepted but silently converted
to 59 seconds + 999999 microseconds.
The optional fractional-part-of-second part can be specified with bigger
or smaller precision -- it will always be transformed to microseconds.
"""
if prestrip:
s = s.strip()
match = ISO_TIME_REGEX.match(s)
if match:
return _make_time_from_match(match)
raise ValueError('could not parse {!r} as ISO time'.format(s))
# TODO: tests
[docs]def parse_iso_datetime(s, prestrip=True):
"""
Parse *ISO-8601*-formatted combined date and time.
Args:
`s`: *ISO-8601*-formatted combined date and time -- as a string.
Kwargs:
`prestrip` (default: :obj:`True`):
Whether the :meth:`strip` method should be called on the
input string before performing the actual processing.
Returns:
A :class:`datetime.datetime` instance (a TZ-aware one if the
input does include time zone information, otherwise a naive
one).
Raises:
:exc:`exceptions.ValueError` for invalid input.
For notes about some limitations -- see :func:`parse_iso_date` and
:func:`parse_iso_time`.
"""
if prestrip:
s = s.strip()
match = ISO_DATETIME_REGEX.match(s)
if match:
d = _make_date_from_match(match)
t = _make_time_from_match(match)
if match.group('hour') == '24':
d += datetime.timedelta(1)
return datetime.datetime.combine(d, t)
raise ValueError('could not parse {!r} as ISO combined date + time'
.format(s))
# TODO: more tests (and convert doctests into unittests)
[docs]def parse_iso_datetime_to_utc(s, prestrip=True):
"""
Parse *ISO-8601*-formatted combined date and time, and normalize it to UTC.
Args:
`s`: *ISO-8601*-formatted combined date and time -- as a string.
Kwargs:
`prestrip` (default: :obj:`True`):
Whether the :meth:`strip` method should be called on the
input string before performing the actual processing.
Returns:
A :class:`datetime.datetime` instance (a naive one, normalized
to UTC).
Raises:
:exc:`exceptions.ValueError` for invalid input.
This function processes input by calling :func:`parse_iso_datetime`
and :func:`datetime_utc_normalize`.
>>> parse_iso_datetime_to_utc('2013-06-13T10:02Z')
datetime.datetime(2013, 6, 13, 10, 2)
>>> parse_iso_datetime_to_utc('2013-06-13 10:02')
datetime.datetime(2013, 6, 13, 10, 2)
>>> parse_iso_datetime_to_utc('2013-06-13 10:02+02:00')
datetime.datetime(2013, 6, 13, 8, 2)
>>> parse_iso_datetime_to_utc('2013-06-13T22:02:04.1234-07:00')
datetime.datetime(2013, 6, 14, 5, 2, 4, 123400)
>>> parse_iso_datetime_to_utc('2013-06-13 10:02:04.123456789Z')
datetime.datetime(2013, 6, 13, 10, 2, 4, 123456)
>>> parse_iso_datetime_to_utc(' 2013-06-13T10:02Z \t')
datetime.datetime(2013, 6, 13, 10, 2)
>>> parse_iso_datetime_to_utc(' 2013-06-13T10:02Z \t', prestrip=False)
... # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValueError: ...
"""
return datetime_utc_normalize(parse_iso_datetime(s, prestrip=prestrip))
# TODO: doc, tests
def _make_date_from_match(match):
g = match.groupdict()
if g['month']:
return datetime.date(int(g['year']),
int(g['month']),
int(g['day']))
elif g['isoweek']:
return date_by_isoweekday(int(g['year']),
int(g['isoweek']),
int(g['isoweekday']))
else:
year = int(g['year'])
ordinalday = int(g['ordinalday'])
if not 1 <= ordinalday <= 366:
raise ValueError('ordinal day number {!r} is out of '
'range 001..366'.format(ordinalday))
if ordinalday == 366 and not calendar.isleap(year):
raise ValueError('ordinal day number {!r} is out of range '
'for year {!r} (which is not a leap year)'
.format(ordinalday, year))
return date_by_ordinalday(year, ordinalday)
def _make_time_from_match(match):
g = match.groupdict()
hour = int(g['hour'])
if hour == 24:
hour = 0
minute = int(g['minute'])
if g['secondfraction']:
fract_str = g['secondfraction']
microsecond = (int(fract_str) * 1000000) // (10 ** len(fract_str))
microsecond = min(microsecond, 999999) # must be less than million
else:
microsecond = 0
if g['second']:
second = int(g['second'])
if second == 60: # ISO 'leap second' -- not supported by datetime
second = 59
microsecond = max(microsecond, 999999)
else:
second = 0
if g['tzhour']:
utc_offset = int(g['tzhour']) * 60
if 'tzminute' in g:
tzminute = int(g['tzminute'])
if tzminute > 59:
raise ValueError('minute part {!r} in time zone designator '
'is out of range 00..59'.format(tzminute))
if utc_offset >= 0:
utc_offset += tzminute
else:
utc_offset -= tzminute
tzinfo = FixedOffsetTimezone(utc_offset)
else:
tzinfo = None
return datetime.time(hour, minute, second, microsecond, tzinfo)
# TODO: better doc, tests
[docs]def date_by_ordinalday(year, ordinalday):
"""
Returns:
An equivalent :class:`datetime.date` instance.
"""
try:
return datetime.date(year, 1, 1) + datetime.timedelta(ordinalday - 1)
except OverflowError as exc:
raise ValueError(*exc.args)
# TODO: better doc, better tests (see below)
[docs]def date_by_isoweekday(isoyear, isoweek, isoweekday):
"""
Returns:
An equivalent :class:`datetime.date` instance
(see: http://en.wikipedia.org/wiki/ISO_week_date).
"""
if not 1 <= isoweek <= 53:
raise ValueError('ISO week number {!r} is out of range 01..53'
.format(isoweek))
if not 1 <= isoweekday <= 7:
raise ValueError('ISO week day number {!r} is out of range 1..7'
.format(isoweekday))
year_specific_correction = datetime.date(isoyear, 1, 4).isoweekday() + 3
ordinalday = 7 * isoweek + isoweekday - year_specific_correction
d = date_by_ordinalday(isoyear, ordinalday)
d_isocalendar = d.isocalendar()
if d_isocalendar != (isoyear, isoweek, isoweekday):
assert isoweek == 53 and d_isocalendar == (isoyear + 1, 1, isoweekday)
raise ValueError('ISO week number {!r} is out of range for ISO-week-'
'numbering year {!r}'.format(isoweek, isoyear))
return d
### XXX: make a unittest from it
def _test_date_by_isoweekday():
"""
Quick'n'dirty test of date_by_isoweekday().
>>> _test_date_by_isoweekday()
"""
from random import randint as r
for i in xrange(100000):
isoyear = r(1, 9998)
isoweek = r(1, 53)
isoweekday = r(1, 7)
try:
d = date_by_isoweekday(isoyear, isoweek, isoweekday)
except ValueError:
# resulting date's year would be > 9999 (illegal for datetime)...
if (isoyear, isoweek, isoweekday) < (9999, 52, 6):
# ...or iso_week=53 is too big for a particular iso_year
assert isoweek == 53
d2 = date_by_isoweekday(isoyear, 52, isoweekday)
assert d2.isocalendar() == (isoyear, 52, isoweekday)
else:
assert d.isocalendar() == (isoyear, isoweek, isoweekday)