diff options
author | Brock Mendel <jbrockmendel@gmail.com> | 2017-12-07 08:51:06 -0800 |
---|---|---|
committer | Brock Mendel <jbrockmendel@gmail.com> | 2017-12-07 08:51:06 -0800 |
commit | 40108c4b5b0ac6c3edcae422c86ce6d87a22d9dc (patch) | |
tree | 89be43fd84b126b5d4491c0bf2b82dfae0005466 /dateutil | |
parent | 167bb3534d063185cd0591fc44c5d83f06d291e6 (diff) | |
parent | 324d6d5a0dd962cf4d81776c31328c40f1d6087e (diff) | |
download | dateutil-40108c4b5b0ac6c3edcae422c86ce6d87a22d9dc.tar.gz |
Merge branch 'master' of https://github.com/dateutil/dateutil into build_methods
Diffstat (limited to 'dateutil')
-rw-r--r-- | dateutil/__init__.py | 5 | ||||
-rw-r--r-- | dateutil/_version.py | 13 | ||||
-rw-r--r-- | dateutil/parser/__init__.py | 5 | ||||
-rw-r--r-- | dateutil/parser/_parser.py | 15 | ||||
-rw-r--r-- | dateutil/parser/isoparser.py | 383 | ||||
-rw-r--r-- | dateutil/relativedelta.py | 18 | ||||
-rw-r--r-- | dateutil/rrule.py | 12 | ||||
-rw-r--r-- | dateutil/test/test_isoparser.py | 467 | ||||
-rw-r--r-- | dateutil/test/test_parser.py | 18 | ||||
-rw-r--r-- | dateutil/test/test_relativedelta.py | 25 | ||||
-rw-r--r-- | dateutil/test/test_rrule.py | 2 | ||||
-rw-r--r-- | dateutil/tz/tz.py | 23 | ||||
-rw-r--r-- | dateutil/zoneinfo/__init__.py | 2 | ||||
-rw-r--r-- | dateutil/zoneinfo/rebuild.py | 2 |
14 files changed, 951 insertions, 39 deletions
diff --git a/dateutil/__init__.py b/dateutil/__init__.py index 8d153dc..0defb82 100644 --- a/dateutil/__init__.py +++ b/dateutil/__init__.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- -from ._version import VERSION as __version__ +try: + from ._version import version as __version__ +except ImportError: + __version__ = 'unknown' __all__ = ['easter', 'parser', 'relativedelta', 'rrule', 'tz', 'utils', 'zoneinfo'] diff --git a/dateutil/_version.py b/dateutil/_version.py deleted file mode 100644 index 1b75426..0000000 --- a/dateutil/_version.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Contains information about the dateutil version. -""" - -VERSION_MAJOR = 2 -VERSION_MINOR = 7 -VERSION_PATCH = 0 - -VERSION_TUPLE = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) -VERSION = '.'.join(map(str, VERSION_TUPLE)) - -# Dev build -VERSION += 'dev0' diff --git a/dateutil/parser/__init__.py b/dateutil/parser/__init__.py index 2d6b1a3..2cc195a 100644 --- a/dateutil/parser/__init__.py +++ b/dateutil/parser/__init__.py @@ -3,7 +3,12 @@ from ._parser import parse, parser, parserinfo from ._parser import DEFAULTPARSER, DEFAULTTZPARSER from ._parser import InvalidDateError, InvalidDatetimeError, InvalidTimeError +from ._parser import __doc__ + +from .isoparser import isoparser, isoparse + __all__ = ['parse', 'parser', 'parserinfo', + 'isoparse', 'isoparser', 'InvalidDatetimeError', 'InvalidDateError', 'InvalidTimeError'] diff --git a/dateutil/parser/_parser.py b/dateutil/parser/_parser.py index e1ce0f6..62ace3d 100644 --- a/dateutil/parser/_parser.py +++ b/dateutil/parser/_parser.py @@ -6,6 +6,7 @@ most known formats to represent a date and/or time. This module attempts to be forgiving with regards to unlikely input formats, returning a datetime object even for dates which are ambiguous. If an element of a date/time stamp is omitted, the following rules are applied: + - If AM or PM is left unspecified, a 24-hour clock is assumed, however, an hour on a 12-hour clock (``0 <= hour <= 12``) *must* be specified if AM or PM is specified. @@ -21,7 +22,7 @@ Additional resources about date/time string formats can be found below: - `A summary of the international standard date and time notation <http://www.cl.cam.ac.uk/~mgk25/iso-time.html>`_ - `W3C Date and Time Formats <http://www.w3.org/TR/NOTE-datetime>`_ -- `Time Formats (Planetary Rings Node) <http://pds-rings.seti.org/tools/time_formats.html>`_ +- `Time Formats (Planetary Rings Node) <https://pds-rings.seti.org:443/tools/time_formats.html>`_ - `CPAN ParseDate module <http://search.cpan.org/~muir/Time-modules-2013.0912/lib/Time/ParseDate.pm>`_ - `Java SimpleDateFormat Class @@ -41,6 +42,8 @@ from io import StringIO import six from six import binary_type, integer_types, text_type +from decimal import Decimal + from .. import relativedelta from .. import tz @@ -572,7 +575,7 @@ class parser(object): This parameter is ignored if ``ignoretz`` is set. - :param **kwargs: + :param \\*\\*kwargs: Keyword arguments as passed to ``_parse()``. :return: @@ -833,7 +836,7 @@ class parser(object): def _parse_numeric_token(self, tokens, idx, info, ymd, res, fuzzy): # Token is a number value_repr = tokens[idx] - value = float(value_repr) + value = Decimal(value_repr) len_li = len(value_repr) len_l = len(tokens) @@ -892,7 +895,7 @@ class parser(object): elif idx + 2 < len_l and tokens[idx + 1] == ':': # HH:MM[:SS[.ss]] res.hour = int(value) - value = float(tokens[idx + 2]) # TODO: try/except for this? + value = Decimal(tokens[idx + 2]) # TODO: try/except for this? (res.minute, res.second) = self._parse_min_sec(value) if idx + 4 < len_l and tokens[idx + 3] == ':': @@ -992,7 +995,9 @@ class parser(object): return hms_idx def _assign_hms(self, res, value_repr, hms): - value = float(value_repr) + # See GH issue #427, fixing float rounding + value = Decimal(value_repr) + if hms == 0: # Hour res.hour = int(value) diff --git a/dateutil/parser/isoparser.py b/dateutil/parser/isoparser.py new file mode 100644 index 0000000..1550c3a --- /dev/null +++ b/dateutil/parser/isoparser.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +""" +This module offers a parser for ISO-8601 strings + +It is intended to support all valid date, time and datetime formats per the +ISO-8601 specification. +""" +from datetime import datetime, timedelta, time, date +import calendar +from dateutil import tz + +from functools import wraps + +import re +import six + +__all__ = ["isoparse", "isoparser"] + + +def _takes_ascii(f): + @wraps(f) + def func(self, str_in, *args, **kwargs): + # If it's a stream, read the whole thing + str_in = getattr(str_in, 'read', lambda: str_in)() + + # If it's unicode, turn it into bytes, since ISO-8601 only covers ASCII + if isinstance(str_in, six.text_type): + # ASCII is the same in UTF-8 + try: + str_in = str_in.encode('ascii') + except UnicodeEncodeError as e: + msg = 'ISO-8601 strings should contain only ASCII characters' + six.raise_from(ValueError(msg), e) + + return f(self, str_in, *args, **kwargs) + + return func + + +class isoparser(object): + def __init__(self, sep='T'): + """ + :param sep: + A single character that separates date and time portions + """ + if (len(sep) != 1 or ord(sep) >= 128 or sep in '0123456789'): + raise ValueError('Separator must be a single, non-numeric ' + 'ASCII character') + + self._sep = sep.encode('ascii') + + @_takes_ascii + def isoparse(self, dt_str): + """ + Parse an ISO-8601 datetime string into a :class:`datetime.datetime`. + + An ISO-8601 datetime string consists of a date portion, followed + optionally by a time portion - the date and time portions are separated + by a single character separator, which is ``T`` in the official + standard. + + Supported date formats are: + + Common: + + - ``YYYY`` + - ``YYYY-MM`` or ``YYYYMM`` + - ``YYYY-MM-DD`` or ``YYYYMMDD`` + + Uncommon: + + - ``YYYY-Www`` or ``YYYYWww`` - ISO week (day defaults to 0) + - ``YYYY-Www-D`` or ``YYYYWwwD`` - ISO week and day + + The ISO week and day numbering follows the same logic as + :func:`datetime.date.isocalendar`. + + Supported time formats are: + + - ``hh`` + - ``hh:mm`` or ``hhmm`` + - ``hh:mm:ss`` or ``hhmmss`` + - ``hh:mm:ss.sss`` or ``hh:mm:ss.ssssss`` (3-6 sub-second digits) + + Midnight is a special case for `hh`, as the standard supports both + 00:00 and 24:00 as a representation. + + .. caution:: + + Support for fractional components other than seconds is part of the + ISO-8601 standard, but is not currently implemented in this parser. + + Supported time zone offset formats are: + + - `Z` (UTC) + - `±HH:MM` + - `±HHMM` + - `±HH` + + Offsets will be represented as :class:`dateutil.tz.tzoffset` objects, + with the exception of UTC, which will be represented as + :class:`dateutil.tz.tzutc`. Time zone offsets equivalent to UTC (such + as `+00:00`) will also be represented as :class:`dateutil.tz.tzutc`. + + :param dt_str: + A string or stream containing only an ISO-8601 datetime string + + :return: + Returns a :class:`datetime.datetime` representing the string. + Unspecified components default to their lowest value. + """ + components, pos = self._parse_isodate(dt_str) + + if len(dt_str) > pos: + if dt_str[pos:pos + 1] == self._sep: + components += self._parse_isotime(dt_str[pos + 1:]) + else: + raise ValueError('String contains unknown ISO components') + + return datetime(*components) + + @_takes_ascii + def parse_isodate(self, datestr): + """ + Parse the date portion of an ISO string. + + :param datestr: + The string portion of an ISO string, without a separator + + :return: + Returns a :class:`datetime.date` object + """ + components, pos = self._parse_isodate(datestr) + if pos < len(datestr): + raise ValueError('String contains unknown ISO ' + + 'components: {}'.format(datestr)) + return date(*components) + + @_takes_ascii + def parse_isotime(self, timestr): + """ + Parse the time portion of an ISO string. + + :param timestr: + The time portion of an ISO string, without a separator + + :return: + Returns a :class:`datetime.time` object + """ + return time(*self._parse_isotime(timestr)) + + @_takes_ascii + def parse_tzstr(self, tzstr, zero_as_utc=True): + """ + Parse a valid ISO time zone string. + + See :func:`isoparser.isoparse` for details on supported formats. + + :param tzstr: + A string representing an ISO time zone offset + + :param zero_as_utc: + Whether to return :class:`dateutil.tz.tzutc` for zero-offset zones + + :return: + Returns :class:`dateutil.tz.tzoffset` for offsets and + :class:`dateutil.tz.tzutc` for ``Z`` and (if ``zero_as_utc`` is + specified) offsets equivalent to UTC. + """ + return self._parse_tzstr(tzstr, zero_as_utc=zero_as_utc) + + # Constants + _MICROSECOND_END_REGEX = re.compile(b'[-+Z]+') + _DATE_SEP = b'-' + _TIME_SEP = b':' + _MICRO_SEP = b'.' + + def _parse_isodate(self, dt_str): + try: + return self._parse_isodate_common(dt_str) + except ValueError: + return self._parse_isodate_uncommon(dt_str) + + def _parse_isodate_common(self, dt_str): + len_str = len(dt_str) + components = [1, 1, 1] + + if len_str < 4: + raise ValueError('ISO string too short') + + # Year + components[0] = int(dt_str[0:4]) + pos = 4 + if pos >= len_str: + return components, pos + + has_sep = dt_str[pos:pos + 1] == self._DATE_SEP + if has_sep: + pos += 1 + + # Month + if len_str - pos < 2: + raise ValueError('Invalid common month') + + components[1] = int(dt_str[pos:pos + 2]) + pos += 2 + + if pos >= len_str: + return components, pos + + if has_sep: + if dt_str[pos:pos + 1] != self._DATE_SEP: + raise ValueError('Invalid separator in ISO string') + pos += 1 + + # Day + if len_str - pos < 2: + raise ValueError('Invalid common day') + components[2] = int(dt_str[pos:pos + 2]) + return components, pos + 2 + + def _parse_isodate_uncommon(self, dt_str): + if len(dt_str) < 4: + raise ValueError('ISO string too short') + + # All ISO formats start with the year + year = int(dt_str[0:4]) + + has_sep = dt_str[4:5] == self._DATE_SEP + + pos = 4 + has_sep # Skip '-' if it's there + if dt_str[pos:pos + 1] == b'W': + # YYYY-?Www-?D? + pos += 1 + weekno = int(dt_str[pos:pos + 2]) + pos += 2 + + dayno = 1 + if len(dt_str) > pos: + if (dt_str[pos:pos + 1] == self._DATE_SEP) != has_sep: + raise ValueError('Inconsistent use of dash separator') + + pos += has_sep + + dayno = int(dt_str[pos:pos + 1]) + pos += 1 + + base_date = self._calculate_weekdate(year, weekno, dayno) + else: + # YYYYDDD or YYYY-DDD + if len(dt_str) - pos < 3: + raise ValueError('Invalid ordinal day') + + ordinal_day = int(dt_str[pos:pos + 3]) + pos += 3 + + if ordinal_day < 1 or ordinal_day > (365 + calendar.isleap(year)): + raise ValueError('Invalid ordinal day' + + ' {} for year {}'.format(ordinal_day, year)) + + base_date = date(year, 1, 1) + timedelta(days=ordinal_day - 1) + + components = [base_date.year, base_date.month, base_date.day] + return components, pos + + def _calculate_weekdate(self, year, week, day): + """ + Calculate the day of corresponding to the ISO year-week-day calendar. + + This function is effectively the inverse of + :func:`datetime.date.isocalendar`. + + :param year: + The year in the ISO calendar + + :param week: + The week in the ISO calendar - range is [1, 53] + + :param day: + The day in the ISO calendar - range is [1 (MON), 7 (SUN)] + + :return: + Returns a :class:`datetime.date` + """ + if not 0 < week < 54: + raise ValueError('Invalid week: {}'.format(week)) + + if not 0 < day < 8: # Range is 1-7 + raise ValueError('Invalid weekday: {}'.format(day)) + + # Get week 1 for the specific year: + jan_4 = date(year, 1, 4) # Week 1 always has January 4th in it + week_1 = jan_4 - timedelta(days=jan_4.isocalendar()[2] - 1) + + # Now add the specific number of weeks and days to get what we want + week_offset = (week - 1) * 7 + (day - 1) + return week_1 + timedelta(days=week_offset) + + def _parse_isotime(self, timestr): + len_str = len(timestr) + components = [0, 0, 0, 0, None] + pos = 0 + comp = -1 + + if len(timestr) < 2: + raise ValueError('ISO time too short') + + has_sep = len_str >= 3 and timestr[2:3] == self._TIME_SEP + + while pos < len_str and comp < 5: + comp += 1 + + if timestr[pos:pos + 1] in b'-+Z': + # Detect time zone boundary + components[-1] = self._parse_tzstr(timestr[pos:]) + pos = len_str + break + + if comp < 3: + # Hour, minute, second + components[comp] = int(timestr[pos:pos + 2]) + pos += 2 + if (has_sep and pos < len_str and + timestr[pos:pos + 1] == self._TIME_SEP): + pos += 1 + + if comp == 3: + # Microsecond + if timestr[pos:pos + 1] != self._MICRO_SEP: + continue + + pos += 1 + us_str = self._MICROSECOND_END_REGEX.split(timestr[pos:pos + 6], + 1)[0] + + components[comp] = int(us_str) * 10**(6 - len(us_str)) + pos += len(us_str) + + if pos < len_str: + raise ValueError('Unused components in ISO string') + + if components[0] == 24: + # Standard supports 00:00 and 24:00 as representations of midnight + if any(component != 0 for component in components[1:4]): + raise ValueError('Hour may only be 24 at 24:00:00.000') + components[0] = 0 + + return components + + def _parse_tzstr(self, tzstr, zero_as_utc=True): + if tzstr == b'Z': + return tz.tzutc() + + if len(tzstr) not in {3, 5, 6}: + raise ValueError('Time zone offset must be 1, 3, 5 or 6 characters') + + if tzstr[0:1] == b'-': + mult = -1 + elif tzstr[0:1] == b'+': + mult = 1 + else: + raise ValueError('Time zone offset requires sign') + + hours = int(tzstr[1:3]) + if len(tzstr) == 3: + minutes = 0 + else: + minutes = int(tzstr[(4 if tzstr[3:4] == self._TIME_SEP else 3):]) + + if zero_as_utc and hours == 0 and minutes == 0: + return tz.tzutc() + else: + if minutes > 59: + raise ValueError('Invalid minutes in time zone offset') + + if hours > 23: + raise ValueError('Invalid hours in time zone offset') + + return tz.tzoffset(None, mult * (hours * 60 + minutes) * 60) + + +DEFAULT_ISOPARSER = isoparser() +isoparse = DEFAULT_ISOPARSER.isoparse diff --git a/dateutil/relativedelta.py b/dateutil/relativedelta.py index 067b5a5..584ed5a 100644 --- a/dateutil/relativedelta.py +++ b/dateutil/relativedelta.py @@ -19,7 +19,7 @@ class relativedelta(object): """ The relativedelta type is based on the specification of the excellent work done by M.-A. Lemburg in his - `mx.DateTime <http://www.egenix.com/files/python/mxDateTime.html>`_ extension. + `mx.DateTime <https://www.egenix.com/products/python/mxBase/mxDateTime/>`_ extension. However, notice that this type does *NOT* implement the same algorithm as his work. Do *NOT* expect it to behave like mx.DateTime's counterpart. @@ -34,7 +34,7 @@ class relativedelta(object): year, month, day, hour, minute, second, microsecond: Absolute information (argument is singular); adding or subtracting a - relativedelta with absolute information does not perform an aritmetic + relativedelta with absolute information does not perform an arithmetic operation, but rather REPLACES the corresponding value in the original datetime with the value(s) in relativedelta. @@ -95,11 +95,6 @@ class relativedelta(object): yearday=None, nlyearday=None, hour=None, minute=None, second=None, microsecond=None): - # Check for non-integer values in integer-only quantities - if any(x is not None and x != int(x) for x in (years, months)): - raise ValueError("Non-integer years and months are " - "ambiguous and not currently supported.") - if dt1 and dt2: # datetime is a subclass of date. So both must be date if not (isinstance(dt1, datetime.date) and @@ -159,9 +154,14 @@ class relativedelta(object): self.seconds = delta.seconds + delta.days * 86400 self.microseconds = delta.microseconds else: + # Check for non-integer values in integer-only quantities + if any(x is not None and x != int(x) for x in (years, months)): + raise ValueError("Non-integer years and months are " + "ambiguous and not currently supported.") + # Relative information - self.years = years - self.months = months + self.years = int(years) + self.months = int(months) self.days = days + weeks * 7 self.leapdays = leapdays self.hours = hours diff --git a/dateutil/rrule.py b/dateutil/rrule.py index 9c6cd91..4074451 100644 --- a/dateutil/rrule.py +++ b/dateutil/rrule.py @@ -2,7 +2,7 @@ """ The rrule module offers a small, complete, and very fast, implementation of the recurrence rules documented in the -`iCalendar RFC <http://www.ietf.org/rfc/rfc2445.txt>`_, +`iCalendar RFC <https://tools.ietf.org/html/rfc5545>`_, including support for caching of results. """ import itertools @@ -359,7 +359,7 @@ class rrule(rrulebase): .. note:: As of version 2.5.0, the use of the ``until`` keyword together - with the ``count`` keyword is deprecated per RFC-2445 Sec. 4.3.10. + with the ``count`` keyword is deprecated per RFC-5545 Sec. 3.3.10. :param until: If given, this must be a datetime instance, that will specify the limit of the recurrence. The last recurrence in the rule is the greatest @@ -368,7 +368,7 @@ class rrule(rrulebase): .. note:: As of version 2.5.0, the use of the ``until`` keyword together - with the ``count`` keyword is deprecated per RFC-2445 Sec. 4.3.10. + with the ``count`` keyword is deprecated per RFC-5545 Sec. 3.3.10. :param bysetpos: If given, it must be either an integer, or a sequence of integers, positive or negative. Each given integer will specify an occurrence @@ -447,7 +447,7 @@ class rrule(rrulebase): self._until = until if count is not None and until: - warn("Using both 'count' and 'until' is inconsistent with RFC 2445" + warn("Using both 'count' and 'until' is inconsistent with RFC 5545" " and has been deprecated in dateutil. Future versions will " "raise an error.", DeprecationWarning) @@ -674,7 +674,7 @@ class rrule(rrulebase): def __str__(self): """ Output a string that would generate this RRULE if passed to rrulestr. - This is mostly compatible with RFC2445, except for the + This is mostly compatible with RFC5545, except for the dateutil-specific extension BYEASTER. """ @@ -699,7 +699,7 @@ class rrule(rrulebase): if self._original_rule.get('byweekday') is not None: # The str() method on weekday objects doesn't generate - # RFC2445-compliant strings, so we should modify that. + # RFC5545-compliant strings, so we should modify that. original_rule = dict(self._original_rule) wday_strings = [] for wday in original_rule['byweekday']: diff --git a/dateutil/test/test_isoparser.py b/dateutil/test/test_isoparser.py new file mode 100644 index 0000000..da6655d --- /dev/null +++ b/dateutil/test/test_isoparser.py @@ -0,0 +1,467 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from datetime import datetime, timedelta, date, time +import itertools as it + +from dateutil.tz import tz +from dateutil.parser import isoparser, isoparse + +import pytest +import six + +UTC = tz.tzutc() + +def _generate_tzoffsets(limited): + def _mkoffset(hmtuple, fmt): + h, m = hmtuple + m_td = (-1 if h < 0 else 1) * m + + tzo = tz.tzoffset(None, timedelta(hours=h, minutes=m_td)) + return tzo, fmt.format(h, m) + + out = [] + if not limited: + # The subset that's just hours + hm_out_h = [(h, 0) for h in (-23, -5, 0, 5, 23)] + out.extend([_mkoffset(hm, '{:+03d}') for hm in hm_out_h]) + + # Ones that have hours and minutes + hm_out = [] + hm_out_h + hm_out += [(-12, 15), (11, 30), (10, 2), (5, 15), (-5, 30)] + else: + hm_out = [(-5, -0)] + + fmts = ['{:+03d}:{:02d}', '{:+03d}{:02d}'] + out += [_mkoffset(hm, fmt) for hm in hm_out for fmt in fmts] + + # Also add in UTC and naive + out.append((tz.tzutc(), 'Z')) + out.append((None, '')) + + return out + +FULL_TZOFFSETS = _generate_tzoffsets(False) +FULL_TZOFFSETS_AWARE = [x for x in FULL_TZOFFSETS if x[1]] +TZOFFSETS = _generate_tzoffsets(True) + +DATES = [datetime(1996, 1, 1), datetime(2017, 1, 1)] +@pytest.mark.parametrize('dt', tuple(DATES)) +def test_year_only(dt): + dtstr = dt.strftime('%Y') + + assert isoparse(dtstr) == dt + +DATES += [datetime(2000, 2, 1), datetime(2017, 4, 1)] +@pytest.mark.parametrize('dt', tuple(DATES)) +@pytest.mark.parametrize('fmt', + ['%Y%m', '%Y-%m']) +def test_year_month(dt, fmt): + dtstr = dt.strftime(fmt) + + assert isoparse(dtstr) == dt + +DATES += [datetime(2016, 2, 29), datetime(2018, 3, 15)] +YMD_FMTS = ('%Y%m%d', '%Y-%m-%d') +@pytest.mark.parametrize('dt', tuple(DATES)) +@pytest.mark.parametrize('fmt', YMD_FMTS) +def test_year_month_day(dt, fmt): + dtstr = dt.strftime(fmt) + + assert isoparse(dtstr) == dt + +def _isoparse_date_and_time(dt, date_fmt, time_fmt, tzoffset, + microsecond_precision=None): + tzi, offset_str = tzoffset + fmt = date_fmt + 'T' + time_fmt + dt = dt.replace(tzinfo=tzi) + dtstr = dt.strftime(fmt) + + if microsecond_precision is not None: + if not fmt.endswith('%f'): + raise ValueError('Time format has no microseconds!') + + if microsecond_precision != 6: + dtstr = dtstr[:-(6 - microsecond_precision)] + elif microsecond_precision > 6: + raise ValueError('Precision must be 1-6') + + dtstr += offset_str + + assert isoparse(dtstr) == dt + +DATETIMES = [datetime(1998, 4, 16, 12), + datetime(2019, 11, 18, 23), + datetime(2014, 12, 16, 4)] +@pytest.mark.parametrize('dt', tuple(DATETIMES)) +@pytest.mark.parametrize('date_fmt', YMD_FMTS) +@pytest.mark.parametrize('tzoffset', TZOFFSETS) +def test_ymd_h(dt, date_fmt, tzoffset): + _isoparse_date_and_time(dt, date_fmt, '%H', tzoffset) + +DATETIMES = [datetime(2012, 1, 6, 9, 37)] +@pytest.mark.parametrize('dt', tuple(DATETIMES)) +@pytest.mark.parametrize('date_fmt', YMD_FMTS) +@pytest.mark.parametrize('time_fmt', ('%H%M', '%H:%M')) +@pytest.mark.parametrize('tzoffset', TZOFFSETS) +def test_ymd_hm(dt, date_fmt, time_fmt, tzoffset): + _isoparse_date_and_time(dt, date_fmt, time_fmt, tzoffset) + +DATETIMES = [datetime(2003, 9, 2, 22, 14, 2), + datetime(2003, 8, 8, 14, 9, 14), + datetime(2003, 4, 7, 6, 14, 59)] +HMS_FMTS = ('%H%M%S', '%H:%M:%S') +@pytest.mark.parametrize('dt', tuple(DATETIMES)) +@pytest.mark.parametrize('date_fmt', YMD_FMTS) +@pytest.mark.parametrize('time_fmt', HMS_FMTS) +@pytest.mark.parametrize('tzoffset', TZOFFSETS) +def test_ymd_hms(dt, date_fmt, time_fmt, tzoffset): + _isoparse_date_and_time(dt, date_fmt, time_fmt, tzoffset) + +DATETIMES = [datetime(2017, 11, 27, 6, 14, 30, 123456)] +@pytest.mark.parametrize('dt', tuple(DATETIMES)) +@pytest.mark.parametrize('date_fmt', YMD_FMTS) +@pytest.mark.parametrize('time_fmt', (x + '.%f' for x in HMS_FMTS)) +@pytest.mark.parametrize('tzoffset', TZOFFSETS) +@pytest.mark.parametrize('precision', list(range(3, 7))) +def test_ymd_hms_micro(dt, date_fmt, time_fmt, tzoffset, precision): + # Truncate the microseconds to the desired precision for the representation + dt = dt.replace(microsecond=int(round(dt.microsecond, precision-6))) + + _isoparse_date_and_time(dt, date_fmt, time_fmt, tzoffset, precision) + +@pytest.mark.parametrize('tzoffset', FULL_TZOFFSETS) +def test_full_tzoffsets(tzoffset): + dt = datetime(2017, 11, 27, 6, 14, 30, 123456) + date_fmt = '%Y-%m-%d' + time_fmt = '%H:%M:%S.%f' + + _isoparse_date_and_time(dt, date_fmt, time_fmt, tzoffset) + +@pytest.mark.parametrize('dt_str', [ + '2014-04-11T00', + '2014-04-11T24', + '2014-04-11T00:00', + '2014-04-11T24:00', + '2014-04-11T00:00:00', + '2014-04-11T24:00:00', + '2014-04-11T00:00:00.000', + '2014-04-11T24:00:00.000', + '2014-04-11T00:00:00.000000', + '2014-04-11T24:00:00.000000'] +) +def test_datetime_midnight(dt_str): + assert isoparse(dt_str) == datetime(2014, 4, 11, 0, 0, 0, 0) + +## +# Uncommon date formats +TIME_ARGS = ('time_args', + ((None, time(0), None), ) + tuple(('%H:%M:%S.%f', _t, _tz) + for _t, _tz in it.product([time(0), time(9, 30), time(14, 47)], + TZOFFSETS))) + +@pytest.mark.parametrize('isocal,dt_expected',[ + ((2017, 10), datetime(2017, 3, 6)), + ((2020, 1), datetime(2019, 12, 30)), # ISO year != Cal year + ((2004, 53), datetime(2004, 12, 27)), # Only half the week is in 2014 +]) +def test_isoweek(isocal, dt_expected): + # TODO: Figure out how to parametrize this on formats, too + for fmt in ('{:04d}-W{:02d}', '{:04d}W{:02d}'): + dtstr = fmt.format(*isocal) + assert isoparse(dtstr) == dt_expected + +@pytest.mark.parametrize('isocal,dt_expected',[ + ((2016, 13, 7), datetime(2016, 4, 3)), + ((2004, 53, 7), datetime(2005, 1, 2)), # ISO year != Cal year + ((2009, 1, 2), datetime(2008, 12, 30)), # ISO year < Cal year + ((2009, 53, 6), datetime(2010, 1, 2)) # ISO year > Cal year +]) +def test_isoweek_day(isocal, dt_expected): + # TODO: Figure out how to parametrize this on formats, too + for fmt in ('{:04d}-W{:02d}-{:d}', '{:04d}W{:02d}{:d}'): + dtstr = fmt.format(*isocal) + assert isoparse(dtstr) == dt_expected + +@pytest.mark.parametrize('isoord,dt_expected', [ + ((2004, 1), datetime(2004, 1, 1)), + ((2016, 60), datetime(2016, 2, 29)), + ((2017, 60), datetime(2017, 3, 1)), + ((2016, 366), datetime(2016, 12, 31)), + ((2017, 365), datetime(2017, 12, 31)) +]) +def test_iso_ordinal(isoord, dt_expected): + for fmt in ('{:04d}-{:03d}', '{:04d}{:03d}'): + dtstr = fmt.format(*isoord) + + assert isoparse(dtstr) == dt_expected + + +### +# Acceptance of bytes +@pytest.mark.parametrize('isostr,dt', [ + (b'2014', datetime(2014, 1, 1)), + (b'20140204', datetime(2014, 2, 4)), + (b'2014-02-04', datetime(2014, 2, 4)), + (b'2014-02-04T12', datetime(2014, 2, 4, 12)), + (b'2014-02-04T12:30', datetime(2014, 2, 4, 12, 30)), + (b'2014-02-04T12:30:15', datetime(2014, 2, 4, 12, 30, 15)), + (b'2014-02-04T12:30:15.224', datetime(2014, 2, 4, 12, 30, 15, 224000)), + (b'20140204T123015.224', datetime(2014, 2, 4, 12, 30, 15, 224000)), + (b'2014-02-04T12:30:15.224Z', datetime(2014, 2, 4, 12, 30, 15, 224000, + tz.tzutc())), + (b'2014-02-04T12:30:15.224+05:00', + datetime(2014, 2, 4, 12, 30, 15, 224000, + tzinfo=tz.tzoffset(None, timedelta(hours=5))))]) +def test_bytes(isostr, dt): + assert isoparse(isostr) == dt + + +### +# Invalid ISO strings +@pytest.mark.parametrize('isostr,exception', [ + ('201', ValueError), # ISO string too short + ('2012-0425', ValueError), # Inconsistent date separators + ('201204-25', ValueError), # Inconsistent date separators + ('20120425T0120:00', ValueError), # Inconsistent time separators + ('20120425T012500-334', ValueError), # Wrong microsecond separator + ('20120425C012500', ValueError), # Wrong time separator + ('2001-1', ValueError), # YYYY-M not valid + ('2012-04-9', ValueError), # YYYY-MM-D not valid + ('20120411T03:30+', ValueError), # Time zone too short + ('20120411T03:30+1234567', ValueError), # Time zone too long + ('20120411T03:30-25:40', ValueError), # Time zone invalid + ('2012-1a', ValueError), # Invalid month + ('20120411T03:30+00:60', ValueError), # Time zone invalid minutes + ('20120411T03:30+00:61', ValueError), # Time zone invalid minutes + ('20120411T033030.123456012:00', # No sign in time zone + ValueError), + ('2012-W00', ValueError), # Invalid ISO week + ('2012-W55', ValueError), # Invalid ISO week + ('2012-W01-0', ValueError), # Invalid ISO week day + ('2012-W01-8', ValueError), # Invalid ISO week day + ('2013-000', ValueError), # Invalid ordinal day + ('2013-366', ValueError), # Invalid ordinal day + ('2013366', ValueError), # Invalid ordinal day + ('2014-03-12Т12:30:14', ValueError), # Cyrillic T + ('2014-04-21T24:00:01', ValueError), # Invalid use of 24 for midnight + ('2014_W01-1', ValueError), # Invalid separator + ('2014W01-1', ValueError), # Inconsistent use of dashes + ('2014-W011', ValueError), # Inconsistent use of dashes + +]) +def test_iso_raises(isostr, exception): + with pytest.raises(exception): + isoparse(isostr) + + +@pytest.mark.xfail() +@pytest.mark.parametrize('isostr,exception', [ + ('20120425T01:2000', ValueError), # Inconsistent time separators +]) +def test_iso_raises_failing(isostr, exception): + # These are test cases where the current implementation is too lenient + # and need to be fixed + with pytest.raises(exception): + isoparse(isostr) + + +### +# Test ISOParser constructor +@pytest.mark.parametrize('sep', [' ', '9', '🍛']) +def test_isoparser_invalid_sep(sep): + with pytest.raises(ValueError): + isoparser(sep=sep) + + +# This only fails on Python 3 +@pytest.mark.xfail(six.PY3, reason="Fails on Python 3 only") +def test_isoparser_byte_sep(): + dt = datetime(2017, 12, 6, 12, 30, 45) + dt_str = dt.isoformat(sep=str('T')) + + dt_rt = isoparser(sep=b'T').isoparse(dt_str) + + assert dt == dt_rt + + +### +# Test parse_tzstr +@pytest.mark.parametrize('tzoffset', FULL_TZOFFSETS) +def test_parse_tzstr(tzoffset): + dt = datetime(2017, 11, 27, 6, 14, 30, 123456) + date_fmt = '%Y-%m-%d' + time_fmt = '%H:%M:%S.%f' + + _isoparse_date_and_time(dt, date_fmt, time_fmt, tzoffset) + + +@pytest.mark.parametrize('tzstr', [ + '-00:00', '+00:00', '+00', '-00', '+0000', '-0000' +]) +@pytest.mark.parametrize('zero_as_utc', [True, False]) +def test_parse_tzstr_zero_as_utc(tzstr, zero_as_utc): + tzi = isoparser().parse_tzstr(tzstr, zero_as_utc=zero_as_utc) + assert tzi == tz.tzutc() + assert (type(tzi) == tz.tzutc) == zero_as_utc + + +@pytest.mark.parametrize('tzstr,exception', [ + ('00:00', ValueError), # No sign + ('05:00', ValueError), # No sign + ('_00:00', ValueError), # Invalid sign + ('+25:00', ValueError), # Offset too large + ('00:0000', ValueError), # String too long +]) +def test_parse_tzstr_fails(tzstr, exception): + with pytest.raises(exception): + isoparser().parse_tzstr(tzstr) + +### +# Test parse_isodate +def __make_date_examples(): + dates_no_day = [ + date(1999, 12, 1), + date(2016, 2, 1), + ] + + if six.PY3: + # strftime does not support dates before 1900 in Python 2 + dates_no_day.append(date(1000, 11, 1)) + + date_no_day_fmts = ('%Y%m', '%Y-%m') + + o = it.product(dates_no_day, date_no_day_fmts) + + dates_w_day = [ + date(1969, 12, 31), + date(1900, 1, 1), + date(2016, 2, 29), + date(2017, 11, 14) + ] + + dates_w_day_fmts = ('%Y%m%d', '%Y-%m-%d') + o = it.chain(o, it.product(dates_w_day, dates_w_day_fmts)) + + return list(o) + + +@pytest.mark.parametrize('d,dt_fmt', __make_date_examples()) +@pytest.mark.parametrize('as_bytes', [True, False]) +def test_parse_isodate(d, dt_fmt, as_bytes): + d_str = d.strftime(dt_fmt) + + if isinstance(d_str, six.text_type) and as_bytes: + d_str = d_str.encode('ascii') + elif isinstance(d_str, six.binary_type) and not as_bytes: + d_str = d_str.decode('ascii') + + iparser = isoparser() + assert iparser.parse_isodate(d_str) == d + + +@pytest.mark.parametrize('isostr,exception', [ + ('243', ValueError), # ISO string too short + ('2014-0423', ValueError), # Inconsistent date separators + ('201404-23', ValueError), # Inconsistent date separators + ('2014日03月14', ValueError), # Not ASCII + ('2013-02-29', ValueError), # Not a leap year + ('2014/12/03', ValueError), # Wrong separators + ('2014-04-19T', ValueError), # Unknown components +]) +def test_isodate_raises(isostr, exception): + with pytest.raises(exception): + isoparser().parse_isodate(isostr) + + +### +# Test parse_isotime +def __make_time_examples(): + outputs = [] + + # HH + time_h = [time(0), time(8), time(22)] + time_h_fmts = ['%H'] + + outputs.append(it.product(time_h, time_h_fmts)) + + # HHMM / HH:MM + time_hm = [time(0, 0), time(0, 30), time(8, 47), time(16, 1)] + time_hm_fmts = ['%H%M', '%H:%M'] + + outputs.append(it.product(time_hm, time_hm_fmts)) + + # HHMMSS / HH:MM:SS + time_hms = [time(0, 0, 0), time(0, 15, 30), + time(8, 2, 16), time(12, 0), time(16, 2), time(20, 45)] + + time_hms_fmts = ['%H%M%S', '%H:%M:%S'] + + outputs.append(it.product(time_hms, time_hms_fmts)) + + # HHMMSS.ffffff / HH:MM:SS.ffffff + time_hmsu = [time(0, 0, 0, 0), time(4, 15, 3, 247993), + time(14, 21, 59, 948730), + time(23, 59, 59, 999999)] + + time_hmsu_fmts = ['%H%M%S.%f', '%H:%M:%S.%f'] + + outputs.append(it.product(time_hmsu, time_hmsu_fmts)) + + outputs = list(map(list, outputs)) + + # Time zones + ex_naive = list(it.chain.from_iterable(x[0:2] for x in outputs)) + o = it.product(ex_naive, TZOFFSETS) # ((time, fmt), (tzinfo, offsetstr)) + o = ((t.replace(tzinfo=tzi), fmt + off_str) + for (t, fmt), (tzi, off_str) in o) + + outputs.append(o) + + return list(it.chain.from_iterable(outputs)) + + +@pytest.mark.parametrize('time_val,time_fmt', __make_time_examples()) +@pytest.mark.parametrize('as_bytes', [True, False]) +def test_isotime(time_val, time_fmt, as_bytes): + tstr = time_val.strftime(time_fmt) + if isinstance(time_val, six.text_type) and as_bytes: + tstr = tstr.encode('ascii') + elif isinstance(time_val, six.binary_type) and not as_bytes: + tstr = tstr.decode('ascii') + + iparser = isoparser() + + assert iparser.parse_isotime(tstr) == time_val + +@pytest.mark.parametrize('isostr,exception', [ + ('3', ValueError), # ISO string too short + ('14時30分15秒', ValueError), # Not ASCII + ('14_30_15', ValueError), # Invalid separators + ('1430:15', ValueError), # Inconsistent separator use + ('14:30:15.3684000309', ValueError), # Too much us precision + ('25', ValueError), # Invalid hours + ('25:15', ValueError), # Invalid hours + ('14:60', ValueError), # Invalid minutes + ('14:59:61', ValueError), # Invalid seconds + ('14:30:15.3446830500', ValueError), # No sign in time zone + ('14:30:15+', ValueError), # Time zone too short + ('14:30:15+1234567', ValueError), # Time zone invalid + ('14:59:59+25:00', ValueError), # Invalid tz hours + ('14:59:59+12:62', ValueError), # Invalid tz minutes + ('14:59:30_344583', ValueError), # Invalid microsecond separator +]) +def test_isotime_raises(isostr, exception): + iparser = isoparser() + with pytest.raises(exception): + iparser.parse_isotime(isostr) + + +@pytest.mark.xfail() +@pytest.mark.parametrize('isostr,exception', [ + ('14:3015', ValueError), # Inconsistent separator use +]) +def test_isotime_raises_xfail(isostr, exception): + iparser = isoparser() + with pytest.raises(exception): + iparser.parse_isotime(isostr) diff --git a/dateutil/test/test_parser.py b/dateutil/test/test_parser.py index e351b25..ce27e82 100644 --- a/dateutil/test/test_parser.py +++ b/dateutil/test/test_parser.py @@ -1016,6 +1016,14 @@ class TestParseUnimplementedCases(object): expected = datetime(1994, 12, 1) assert res == expected + @pytest.mark.xfail + def test_unambiguous_YYYYMM(self): + # 171206 can be parsed as YYMMDD. However, 201712 cannot be parsed + # as instance of YYMMDD and parser could fallback to YYYYMM format. + dstr = "201712" + res = parse(dstr) + expected = datetime(2017, 12, 1) + assert res == expected @pytest.mark.skipif(IS_WIN, reason='Windows does not use TZ var') def test_parse_unambiguous_nonexistent_local(): @@ -1069,3 +1077,13 @@ def test_parse_tzinfos_fold(): assert dt.tzinfo is dt_exp.tzinfo assert getattr(dt, 'fold') == getattr(dt_exp, 'fold') assert dt.astimezone(tz.tzutc()) == dt_exp.astimezone(tz.tzutc()) + + +@pytest.mark.parametrize('dtstr,dt', [ + ('5.6h', datetime(2003, 9, 25, 5, 36)), + ('5.6m', datetime(2003, 9, 25, 0, 5, 36)), + # '5.6s' never had a rounding problem, test added for completeness + ('5.6s', datetime(2003, 9, 25, 0, 0, 5, 600000)) +]) +def test_rounding_floatlike_strings(dtstr, dt): + assert parse(dtstr, default=datetime(2003, 9, 25)) == dt diff --git a/dateutil/test/test_relativedelta.py b/dateutil/test/test_relativedelta.py index 9891357..70cb543 100644 --- a/dateutil/test/test_relativedelta.py +++ b/dateutil/test/test_relativedelta.py @@ -203,6 +203,31 @@ class RelativeDeltaTest(WarningTestMixin, unittest.TestCase): # For unsupported types that define their own comparators, etc. self.assertIs(relativedelta(days=1) + NotAValue, NotAValue) + def testAdditionFloatValue(self): + self.assertEqual(datetime(2000, 1, 1) + relativedelta(days=float(1)), + datetime(2000, 1, 2)) + self.assertEqual(datetime(2000, 1, 1) + relativedelta(months=float(1)), + datetime(2000, 2, 1)) + self.assertEqual(datetime(2000, 1, 1) + relativedelta(years=float(1)), + datetime(2001, 1, 1)) + + def testAdditionFloatFractionals(self): + self.assertEqual(datetime(2000, 1, 1, 0) + + relativedelta(days=float(0.5)), + datetime(2000, 1, 1, 12)) + self.assertEqual(datetime(2000, 1, 1, 0, 0) + + relativedelta(hours=float(0.5)), + datetime(2000, 1, 1, 0, 30)) + self.assertEqual(datetime(2000, 1, 1, 0, 0, 0) + + relativedelta(minutes=float(0.5)), + datetime(2000, 1, 1, 0, 0, 30)) + self.assertEqual(datetime(2000, 1, 1, 0, 0, 0, 0) + + relativedelta(seconds=float(0.5)), + datetime(2000, 1, 1, 0, 0, 0, 500000)) + self.assertEqual(datetime(2000, 1, 1, 0, 0, 0, 0) + + relativedelta(microseconds=float(500000.25)), + datetime(2000, 1, 1, 0, 0, 0, 500000)) + def testSubtraction(self): self.assertEqual(relativedelta(days=10) - relativedelta(years=1, months=2, days=3, hours=4, diff --git a/dateutil/test/test_rrule.py b/dateutil/test/test_rrule.py index b80d16b..8a53711 100644 --- a/dateutil/test/test_rrule.py +++ b/dateutil/test/test_rrule.py @@ -2363,7 +2363,7 @@ class RRuleTest(WarningTestMixin, unittest.TestCase): def testBadUntilCountRRule(self): """ - See rfc-2445 4.3.10 - This checks for the deprecation warning, and will + See rfc-5545 3.3.10 - This checks for the deprecation warning, and will eventually check for an error. """ with self.assertWarns(DeprecationWarning): diff --git a/dateutil/tz/tz.py b/dateutil/tz/tz.py index 497729c..39e19c7 100644 --- a/dateutil/tz/tz.py +++ b/dateutil/tz/tz.py @@ -38,6 +38,22 @@ class tzutc(datetime.tzinfo): """ This is a tzinfo object that represents the UTC time zone. + **Examples:** + + .. doctest:: + + >>> from datetime import * + >>> from dateutil.tz import * + + >>> datetime.now() + datetime.datetime(2003, 9, 27, 9, 40, 1, 521290) + + >>> datetime.now(tzutc()) + datetime.datetime(2003, 9, 27, 12, 40, 12, 156379, tzinfo=tzutc()) + + >>> datetime.now(tzutc()).tzname() + 'UTC' + .. versionchanged:: 2.7.0 ``tzutc()`` is now a singleton, so the result of ``tzutc()`` will always return the same object. @@ -106,6 +122,7 @@ class tzutc(datetime.tzinfo): class tzoffset(datetime.tzinfo): """ A simple class for representing a fixed offset from UTC. + :param name: The timezone name, to be returned when ``tzname()`` is called. :param offset: @@ -140,10 +157,12 @@ class tzoffset(datetime.tzinfo): """ Whether or not the "wall time" of a given datetime is ambiguous in this zone. + :param dt: A :py:class:`datetime.datetime`, naive or time zone aware. :return: Returns ``True`` if ambiguous, ``False`` otherwise. + .. versionadded:: 2.6.0 """ return False @@ -1144,13 +1163,13 @@ class _tzicalvtz(_tzinfo): class tzical(object): """ This object is designed to parse an iCalendar-style ``VTIMEZONE`` structure - as set out in `RFC 2445`_ Section 4.6.5 into one or more `tzinfo` objects. + as set out in `RFC 5545`_ Section 4.6.5 into one or more `tzinfo` objects. :param `fileobj`: A file or stream in iCalendar format, which should be UTF-8 encoded with CRLF endings. - .. _`RFC 2445`: https://www.ietf.org/rfc/rfc2445.txt + .. _`RFC 5545`: https://tools.ietf.org/html/rfc5545 """ def __init__(self, fileobj): global rrule diff --git a/dateutil/zoneinfo/__init__.py b/dateutil/zoneinfo/__init__.py index 1df1d96..34f11ad 100644 --- a/dateutil/zoneinfo/__init__.py +++ b/dateutil/zoneinfo/__init__.py @@ -8,7 +8,7 @@ from io import BytesIO from dateutil.tz import tzfile as _tzfile -__all__ = ["get_zonefile_instance", "gettz", "gettz_db_metadata", "rebuild"] +__all__ = ["get_zonefile_instance", "gettz", "gettz_db_metadata"] ZONEFILENAME = "dateutil-zoneinfo.tar.gz" METADATA_FN = 'METADATA' diff --git a/dateutil/zoneinfo/rebuild.py b/dateutil/zoneinfo/rebuild.py index 8dd5e1d..78f0d1a 100644 --- a/dateutil/zoneinfo/rebuild.py +++ b/dateutil/zoneinfo/rebuild.py @@ -12,7 +12,7 @@ from dateutil.zoneinfo import METADATA_FN, ZONEFILENAME def rebuild(filename, tag=None, format="gz", zonegroups=[], metadata=None): """Rebuild the internal timezone info in dateutil/zoneinfo/zoneinfo*tar* - filename is the timezone tarball from ftp.iana.org/tz. + filename is the timezone tarball from ``ftp.iana.org/tz``. """ tmpdir = tempfile.mkdtemp() |