From 929c6a71aee8fb9e53693e1070d4f28fa5cf4617 Mon Sep 17 00:00:00 2001 From: JackDandy Date: Tue, 27 Mar 2018 14:46:59 +0100 Subject: [PATCH] =?UTF-8?q?Update=20dateutil=20library=202.6.1=20(2f3a160)?= =?UTF-8?q?=20=E2=86=92=202.7.2=20(ff03c0f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGES.md | 1 + lib/dateutil/__init__.py | 8 +- lib/dateutil/_version.py | 10 - lib/dateutil/easter.py | 4 +- lib/dateutil/parser/__init__.py | 60 ++ lib/dateutil/{parser.py => parser/_parser.py} | 942 ++++++++++-------- lib/dateutil/parser/isoparser.py | 402 ++++++++ lib/dateutil/relativedelta.py | 38 +- lib/dateutil/rrule.py | 60 +- lib/dateutil/tz/__init__.py | 12 +- lib/dateutil/tz/_factories.py | 49 + lib/dateutil/tz/tz.py | 307 ++++-- lib/dateutil/utils.py | 53 + lib/dateutil/zoneinfo/__init__.py | 7 +- lib/dateutil/zoneinfo/rebuild.py | 2 +- sickbeard/network_timezones.py | 1 + 16 files changed, 1407 insertions(+), 549 deletions(-) delete mode 100644 lib/dateutil/_version.py create mode 100644 lib/dateutil/parser/__init__.py rename lib/dateutil/{parser.py => parser/_parser.py} (64%) create mode 100644 lib/dateutil/parser/isoparser.py create mode 100644 lib/dateutil/tz/_factories.py diff --git a/CHANGES.md b/CHANGES.md index bc98416d..3ce3ecb9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ * Update backports/ssl_match_hostname 3.5.0.1 (r18) to 3.7.0.1 (r28) * Update cachecontrol library 0.12.3 (db54c40) to 0.12.4 (bd94f7e) * Update chardet packages 3.0.4 (9b8c5c2) to 4.0.0 (b3d867a) +* Update dateutil library 2.6.1 (2f3a160) to 2.7.2 (ff03c0f) [develop changelog] diff --git a/lib/dateutil/__init__.py b/lib/dateutil/__init__.py index 2185a6be..704e0a91 100644 --- a/lib/dateutil/__init__.py +++ b/lib/dateutil/__init__.py @@ -1,2 +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/lib/dateutil/_version.py b/lib/dateutil/_version.py deleted file mode 100644 index c52c6b58..00000000 --- a/lib/dateutil/_version.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Contains information about the dateutil version. -""" - -VERSION_MAJOR = 2 -VERSION_MINOR = 6 -VERSION_PATCH = 1 - -VERSION_TUPLE = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) -VERSION = '.'.join(map(str, VERSION_TUPLE)) diff --git a/lib/dateutil/easter.py b/lib/dateutil/easter.py index a96cb438..f9034b99 100644 --- a/lib/dateutil/easter.py +++ b/lib/dateutil/easter.py @@ -41,11 +41,11 @@ def easter(year, method=EASTER_WESTERN): More about the algorithm may be found at: - http://users.chariot.net.au/~gmarts/eastalg.htm + `GM Arts: Easter Algorithms `_ and - http://www.tondering.dk/claus/calendar.html + `The Calendar FAQ: Easter `_ """ diff --git a/lib/dateutil/parser/__init__.py b/lib/dateutil/parser/__init__.py new file mode 100644 index 00000000..c37ccc1c --- /dev/null +++ b/lib/dateutil/parser/__init__.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from ._parser import parse, parser, parserinfo +from ._parser import DEFAULTPARSER, DEFAULTTZPARSER +from ._parser import UnknownTimezoneWarning + +from ._parser import __doc__ + +from .isoparser import isoparser, isoparse + +__all__ = ['parse', 'parser', 'parserinfo', + 'isoparse', 'isoparser', + 'UnknownTimezoneWarning'] + + +### +# Deprecate portions of the private interface so that downstream code that +# is improperly relying on it is given *some* notice. + + +def __deprecated_private_func(f): + from functools import wraps + import warnings + + msg = ('{name} is a private function and may break without warning, ' + 'it will be moved and or renamed in future versions.') + msg = msg.format(name=f.__name__) + + @wraps(f) + def deprecated_func(*args, **kwargs): + warnings.warn(msg, DeprecationWarning) + return f(*args, **kwargs) + + return deprecated_func + +def __deprecate_private_class(c): + import warnings + + msg = ('{name} is a private class and may break without warning, ' + 'it will be moved and or renamed in future versions.') + msg = msg.format(name=c.__name__) + + class private_class(c): + __doc__ = c.__doc__ + + def __init__(self, *args, **kwargs): + warnings.warn(msg, DeprecationWarning) + super(private_class, self).__init__(*args, **kwargs) + + private_class.__name__ = c.__name__ + + return private_class + + +from ._parser import _timelex, _resultbase +from ._parser import _tzparser, _parsetz + +_timelex = __deprecate_private_class(_timelex) +_tzparser = __deprecate_private_class(_tzparser) +_resultbase = __deprecate_private_class(_resultbase) +_parsetz = __deprecated_private_func(_parsetz) diff --git a/lib/dateutil/parser.py b/lib/dateutil/parser/_parser.py similarity index 64% rename from lib/dateutil/parser.py rename to lib/dateutil/parser/_parser.py index 0e831481..014050c3 100644 --- a/lib/dateutil/parser.py +++ b/lib/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 `_ - `W3C Date and Time Formats `_ -- `Time Formats (Planetary Rings Node) `_ +- `Time Formats (Planetary Rings Node) `_ - `CPAN ParseDate module `_ - `Java SimpleDateFormat Class @@ -29,19 +30,24 @@ Additional resources about date/time string formats can be found below: """ from __future__ import unicode_literals -import collections import datetime import re import string import time +import warnings from calendar import monthrange from io import StringIO +import six from six import binary_type, integer_types, text_type -from . import relativedelta -from . import tz +from decimal import Decimal + +from warnings import warn + +from .. import relativedelta +from .. import tz __all__ = ["parse", "parserinfo"] @@ -54,13 +60,18 @@ class _timelex(object): _split_decimal = re.compile("([.,])") def __init__(self, instream): - if isinstance(instream, binary_type): - instream = instream.decode() + if six.PY2: + # In Python 2, we can't duck type properly because unicode has + # a 'decode' function, and we'd be double-decoding + if isinstance(instream, (binary_type, bytearray)): + instream = instream.decode() + else: + if getattr(instream, 'decode', None) is not None: + instream = instream.decode() if isinstance(instream, text_type): instream = StringIO(instream) - - if getattr(instream, 'read', None) is None: + elif getattr(instream, 'read', None) is None: raise TypeError('Parser must be a string or character stream, not ' '{itype}'.format(itype=instream.__class__.__name__)) @@ -239,16 +250,16 @@ class parserinfo(object): the language and acceptable values for each parameter. :param dayfirst: - Whether to interpret the first value in an ambiguous 3-integer date - (e.g. 01/05/09) as the day (``True``) or month (``False``). If - ``yearfirst`` is set to ``True``, this distinguishes between YDM - and YMD. Default is ``False``. + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the day (``True``) or month (``False``). If + ``yearfirst`` is set to ``True``, this distinguishes between YDM + and YMD. Default is ``False``. :param yearfirst: - Whether to interpret the first value in an ambiguous 3-integer date - (e.g. 01/05/09) as the year. If ``True``, the first number is taken - to be the year, otherwise the last number is taken to be the year. - Default is ``False``. + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the year. If ``True``, the first number is taken + to be the year, otherwise the last number is taken to be the year. + Default is ``False``. """ # m from a.m/p.m, t from ISO T separator @@ -315,19 +326,17 @@ class parserinfo(object): return name.lower() in self._jump def weekday(self, name): - if len(name) >= min(len(n) for n in self._weekdays.keys()): - try: - return self._weekdays[name.lower()] - except KeyError: - pass + try: + return self._weekdays[name.lower()] + except KeyError: + pass return None def month(self, name): - if len(name) >= min(len(n) for n in self._months.keys()): - try: - return self._months[name.lower()] + 1 - except KeyError: - pass + try: + return self._months[name.lower()] + 1 + except KeyError: + pass return None def hms(self, name): @@ -378,10 +387,9 @@ class parserinfo(object): class _ymd(list): - def __init__(self, tzstr, *args, **kwargs): + def __init__(self, *args, **kwargs): super(self.__class__, self).__init__(*args, **kwargs) self.century_specified = False - self.tzstr = tzstr self.dstridx = None self.mstridx = None self.ystridx = None @@ -398,39 +406,31 @@ class _ymd(list): def has_day(self): return self.dstridx is not None - @staticmethod - def token_could_be_year(token, year): - try: - return int(token) == year - except ValueError: + def could_be_day(self, value): + if self.has_day: return False - - @staticmethod - def find_potential_year_tokens(year, tokens): - return [token for token in tokens - if _ymd.token_could_be_year(token, year)] - - def find_probable_year_index(self, tokens): - """ - attempt to deduce if a pre 100 year was lost - due to padded zeros being taken off - """ - for index, token in enumerate(self): - potential_year_tokens = _ymd.find_potential_year_tokens(token, - tokens) - if (len(potential_year_tokens) == 1 and - len(potential_year_tokens[0]) > 2): - return index + elif not self.has_month: + return 1 <= value <= 31 + elif not self.has_year: + # Be permissive, assume leapyear + month = self[self.mstridx] + return 1 <= value <= monthrange(2000, month)[1] + else: + month = self[self.mstridx] + year = self[self.ystridx] + return 1 <= value <= monthrange(year, month)[1] def append(self, val, label=None): if hasattr(val, '__len__'): if val.isdigit() and len(val) > 2: self.century_specified = True - assert label in [None, 'Y'] + if label not in [None, 'Y']: # pragma: no cover + raise ValueError(label) label = 'Y' elif val > 100: self.century_specified = True - assert label in [None, 'Y'] + if label not in [None, 'Y']: # pragma: no cover + raise ValueError(label) label = 'Y' super(self.__class__, self).append(int(val)) @@ -486,7 +486,11 @@ class _ymd(list): elif len_ymd == 3: # Three members if mstridx == 0: - month, day, year = self + if self[1] > 31: + # Apr-2003-25 + month, year, day = self + else: + month, day, year = self elif mstridx == 1: if self[0] > 31 or (yearfirst and self[2] <= 31): # 99-Jan-01 @@ -508,7 +512,7 @@ class _ymd(list): else: if (self[0] > 31 or - self.find_probable_year_index(_timelex.split(self.tzstr)) == 0 or + self.ystridx == 0 or (yearfirst and self[1] <= 12 and self[2] <= 31)): # 99-01-01 if dayfirst and self[2] <= 12: @@ -555,23 +559,23 @@ class parser(object): ``tzoffset``) and returning a time zone. The timezones to which the names are mapped can be an integer - offset from UTC in minutes or a :class:`tzinfo` object. + offset from UTC in seconds or a :class:`tzinfo` object. .. doctest:: :options: +NORMALIZE_WHITESPACE >>> from dateutil.parser import parse >>> from dateutil.tz import gettz - >>> tzinfos = {"BRST": -10800, "CST": gettz("America/Chicago")} + >>> tzinfos = {"BRST": -7200, "CST": gettz("America/Chicago")} >>> parse("2012-01-19 17:21:00 BRST", tzinfos=tzinfos) - datetime.datetime(2012, 1, 19, 17, 21, tzinfo=tzoffset(u'BRST', -10800)) + datetime.datetime(2012, 1, 19, 17, 21, tzinfo=tzoffset(u'BRST', -7200)) >>> parse("2012-01-19 17:21:00 CST", tzinfos=tzinfos) datetime.datetime(2012, 1, 19, 17, 21, tzinfo=tzfile('/usr/share/zoneinfo/America/Chicago')) This parameter is ignored if ``ignoretz`` is set. - :param **kwargs: + :param \\*\\*kwargs: Keyword arguments as passed to ``_parse()``. :return: @@ -605,39 +609,10 @@ class parser(object): if len(res) == 0: raise ValueError("String does not contain a date:", timestr) - repl = {} - for attr in ("year", "month", "day", "hour", - "minute", "second", "microsecond"): - value = getattr(res, attr) - if value is not None: - repl[attr] = value - - if 'day' not in repl: - # If the default day exceeds the last day of the month, fall back - # to the end of the month. - cyear = default.year if res.year is None else res.year - cmonth = default.month if res.month is None else res.month - cday = default.day if res.day is None else res.day - - if cday > monthrange(cyear, cmonth)[1]: - repl['day'] = monthrange(cyear, cmonth)[1] - - ret = default.replace(**repl) - - if res.weekday is not None and not res.day: - ret = ret + relativedelta.relativedelta(weekday=res.weekday) + ret = self._build_naive(res, default) if not ignoretz: - if (isinstance(tzinfos, collections.Callable) or - tzinfos and res.tzname in tzinfos): - tzinfo = _build_tzinfo(tzinfos, res.tzname, res.tzoffset) - ret = ret.replace(tzinfo=tzinfo) - elif res.tzname and res.tzname in time.tzname: - ret = ret.replace(tzinfo=tz.tzlocal()) - elif res.tzoffset == 0: - ret = ret.replace(tzinfo=tz.tzutc()) - elif res.tzoffset: - ret = ret.replace(tzinfo=tz.tzoffset(res.tzname, res.tzoffset)) + ret = self._build_tzaware(ret, res, tzinfos) if kwargs.get('fuzzy_with_tokens', False): return ret, skipped_tokens @@ -647,7 +622,7 @@ class parser(object): class _result(_resultbase): __slots__ = ["year", "month", "day", "weekday", "hour", "minute", "second", "microsecond", - "tzname", "tzoffset", "ampm"] + "tzname", "tzoffset", "ampm","any_unused_tokens"] def _parse(self, timestr, dayfirst=None, yearfirst=None, fuzzy=False, fuzzy_with_tokens=False): @@ -707,7 +682,7 @@ class parser(object): skipped_idxs = [] # year/month/day list - ymd = _ymd(timestr) + ymd = _ymd() len_l = len(l) i = 0 @@ -715,129 +690,15 @@ class parser(object): while i < len_l: # Check if it's a number + value_repr = l[i] try: - value_repr = l[i] value = float(value_repr) except ValueError: value = None if value is not None: - # Token is a number - len_li = len(l[i]) - - if (len(ymd) == 3 and len_li in (2, 4) and - res.hour is None and - (i + 1 >= len_l or - (l[i + 1] != ':' and - info.hms(l[i + 1]) is None))): - # 19990101T23[59] - s = l[i] - res.hour = int(s[:2]) - - if len_li == 4: - res.minute = int(s[2:]) - - elif len_li == 6 or (len_li > 6 and l[i].find('.') == 6): - # YYMMDD or HHMMSS[.ss] - s = l[i] - - if not ymd and '.' not in l[i]: - ymd.append(s[:2]) - ymd.append(s[2:4]) - ymd.append(s[4:]) - else: - # 19990101T235959[.59] - - # TODO: Check if res attributes already set. - res.hour = int(s[:2]) - res.minute = int(s[2:4]) - res.second, res.microsecond = _parsems(s[4:]) - - elif len_li in (8, 12, 14): - # YYYYMMDD - s = l[i] - ymd.append(s[:4], 'Y') - ymd.append(s[4:6]) - ymd.append(s[6:8]) - - if len_li > 8: - res.hour = int(s[8:10]) - res.minute = int(s[10:12]) - - if len_li > 12: - res.second = int(s[12:]) - - elif _find_hms_idx(i, l, info, allow_jump=True) is not None: - # HH[ ]h or MM[ ]m or SS[.ss][ ]s - hms_idx = _find_hms_idx(i, l, info, allow_jump=True) - (i, hms) = _parse_hms(i, l, info, hms_idx) - if hms is not None: - # TODO: checking that hour/minute/second are not - # already set? - _assign_hms(res, value_repr, hms) - - elif i + 2 < len_l and l[i + 1] == ':': - # HH:MM[:SS[.ss]] - res.hour = int(value) - value = float(l[i + 2]) # TODO: try/except for this? - (res.minute, res.second) = _parse_min_sec(value) - - if i + 4 < len_l and l[i + 3] == ':': - res.second, res.microsecond = _parsems(l[i + 4]) - - i += 2 - - i += 2 - - elif i + 1 < len_l and l[i + 1] in ('-', '/', '.'): - sep = l[i + 1] - ymd.append(value_repr) - - if i + 2 < len_l and not info.jump(l[i + 2]): - if l[i + 2].isdigit(): - # 01-01[-01] - ymd.append(l[i + 2]) - else: - # 01-Jan[-01] - value = info.month(l[i + 2]) - - if value is not None: - ymd.append(value, 'M') - else: - raise InvalidDatetimeError(timestr) - - if i + 3 < len_l and l[i + 3] == sep: - # We have three members - value = info.month(l[i + 4]) - - if value is not None: - ymd.append(value, 'M') - else: - ymd.append(l[i + 4]) - i += 2 - - i += 1 - i += 1 - - elif i + 1 >= len_l or info.jump(l[i + 1]): - if i + 2 < len_l and info.ampm(l[i + 2]) is not None: - # 12 am - hour = int(value) - res.hour = _adjust_ampm(hour, info.ampm(l[i + 2])) - i += 1 - else: - # Year, month or day - ymd.append(value) - i += 1 - - elif info.ampm(l[i + 1]) is not None: - # 12am - hour = int(value) - res.hour = _adjust_ampm(hour, info.ampm(l[i + 1])) - i += 1 - - elif not fuzzy: - raise InvalidDatetimeError(timestr) + # Numeric token + i = self._parse_numeric_token(l, i, info, ymd, res, fuzzy) # Check weekday elif info.weekday(l[i]) is not None: @@ -880,17 +741,17 @@ class parser(object): # Check am/pm elif info.ampm(l[i]) is not None: value = info.ampm(l[i]) - val_is_ampm = _ampm_validity(res.hour, res.ampm, fuzzy) + val_is_ampm = self._ampm_valid(res.hour, res.ampm, fuzzy) if val_is_ampm: - res.hour = _adjust_ampm(res.hour, value) + res.hour = self._adjust_ampm(res.hour, value) res.ampm = value elif fuzzy: skipped_idxs.append(i) # Check for a timezone name - elif _could_be_tzname(res.hour, res.tzname, res.tzoffset, l[i]): + elif self._could_be_tzname(res.hour, res.tzname, res.tzoffset, l[i]): res.tzname = l[i] res.tzoffset = info.tzoffset(res.tzname) @@ -927,17 +788,17 @@ class parser(object): hour_offset = int(l[i + 1][:2]) min_offset = 0 else: - raise InvalidDatetimeError(timestr) + raise ValueError(timestr) res.tzoffset = signal * (hour_offset * 3600 + min_offset * 60) - # TODO: Check if res.tzname is not None # Look for a timezone name between parenthesis if (i + 5 < len_l and - info.jump(l[i + 2]) and l[i + 3] == '(' and - l[i + 5] == ')' and - 3 <= len(l[i + 4]) <= 5 and - all(x in string.ascii_uppercase for x in l[i + 4])): # TODO: merge this with _could_be_tzname + info.jump(l[i + 2]) and l[i + 3] == '(' and + l[i + 5] == ')' and + 3 <= len(l[i + 4]) and + self._could_be_tzname(res.hour, res.tzname, + None, l[i + 4])): # -0300 (BRST) res.tzname = l[i + 4] i += 4 @@ -946,7 +807,7 @@ class parser(object): # Check jumps elif not (info.jump(l[i]) or fuzzy): - raise InvalidDatetimeError(timestr) + raise ValueError(timestr) else: skipped_idxs.append(i) @@ -960,18 +821,392 @@ class parser(object): res.month = month res.day = day - except (IndexError, ValueError, AssertionError): + except (IndexError, ValueError): return None, None if not info.validate(res): return None, None if fuzzy_with_tokens: - skipped_tokens = _recombine_skipped(l, skipped_idxs) + skipped_tokens = self._recombine_skipped(l, skipped_idxs) return res, tuple(skipped_tokens) else: return res, None + def _parse_numeric_token(self, tokens, idx, info, ymd, res, fuzzy): + # Token is a number + value_repr = tokens[idx] + try: + value = self._to_decimal(value_repr) + except Exception as e: + six.raise_from(ValueError('Unknown numeric token'), e) + + len_li = len(value_repr) + + len_l = len(tokens) + + if (len(ymd) == 3 and len_li in (2, 4) and + res.hour is None and + (idx + 1 >= len_l or + (tokens[idx + 1] != ':' and + info.hms(tokens[idx + 1]) is None))): + # 19990101T23[59] + s = tokens[idx] + res.hour = int(s[:2]) + + if len_li == 4: + res.minute = int(s[2:]) + + elif len_li == 6 or (len_li > 6 and tokens[idx].find('.') == 6): + # YYMMDD or HHMMSS[.ss] + s = tokens[idx] + + if not ymd and '.' not in tokens[idx]: + ymd.append(s[:2]) + ymd.append(s[2:4]) + ymd.append(s[4:]) + else: + # 19990101T235959[.59] + + # TODO: Check if res attributes already set. + res.hour = int(s[:2]) + res.minute = int(s[2:4]) + res.second, res.microsecond = self._parsems(s[4:]) + + elif len_li in (8, 12, 14): + # YYYYMMDD + s = tokens[idx] + ymd.append(s[:4], 'Y') + ymd.append(s[4:6]) + ymd.append(s[6:8]) + + if len_li > 8: + res.hour = int(s[8:10]) + res.minute = int(s[10:12]) + + if len_li > 12: + res.second = int(s[12:]) + + elif self._find_hms_idx(idx, tokens, info, allow_jump=True) is not None: + # HH[ ]h or MM[ ]m or SS[.ss][ ]s + hms_idx = self._find_hms_idx(idx, tokens, info, allow_jump=True) + (idx, hms) = self._parse_hms(idx, tokens, info, hms_idx) + if hms is not None: + # TODO: checking that hour/minute/second are not + # already set? + self._assign_hms(res, value_repr, hms) + + elif idx + 2 < len_l and tokens[idx + 1] == ':': + # HH:MM[:SS[.ss]] + res.hour = int(value) + value = self._to_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] == ':': + res.second, res.microsecond = self._parsems(tokens[idx + 4]) + + idx += 2 + + idx += 2 + + elif idx + 1 < len_l and tokens[idx + 1] in ('-', '/', '.'): + sep = tokens[idx + 1] + ymd.append(value_repr) + + if idx + 2 < len_l and not info.jump(tokens[idx + 2]): + if tokens[idx + 2].isdigit(): + # 01-01[-01] + ymd.append(tokens[idx + 2]) + else: + # 01-Jan[-01] + value = info.month(tokens[idx + 2]) + + if value is not None: + ymd.append(value, 'M') + else: + raise ValueError() + + if idx + 3 < len_l and tokens[idx + 3] == sep: + # We have three members + value = info.month(tokens[idx + 4]) + + if value is not None: + ymd.append(value, 'M') + else: + ymd.append(tokens[idx + 4]) + idx += 2 + + idx += 1 + idx += 1 + + elif idx + 1 >= len_l or info.jump(tokens[idx + 1]): + if idx + 2 < len_l and info.ampm(tokens[idx + 2]) is not None: + # 12 am + hour = int(value) + res.hour = self._adjust_ampm(hour, info.ampm(tokens[idx + 2])) + idx += 1 + else: + # Year, month or day + ymd.append(value) + idx += 1 + + elif info.ampm(tokens[idx + 1]) is not None and (0 <= value < 24): + # 12am + hour = int(value) + res.hour = self._adjust_ampm(hour, info.ampm(tokens[idx + 1])) + idx += 1 + + elif ymd.could_be_day(value): + ymd.append(value) + + elif not fuzzy: + raise ValueError() + + return idx + + def _find_hms_idx(self, idx, tokens, info, allow_jump): + len_l = len(tokens) + + if idx+1 < len_l and info.hms(tokens[idx+1]) is not None: + # There is an "h", "m", or "s" label following this token. We take + # assign the upcoming label to the current token. + # e.g. the "12" in 12h" + hms_idx = idx + 1 + + elif (allow_jump and idx+2 < len_l and tokens[idx+1] == ' ' and + info.hms(tokens[idx+2]) is not None): + # There is a space and then an "h", "m", or "s" label. + # e.g. the "12" in "12 h" + hms_idx = idx + 2 + + elif idx > 0 and info.hms(tokens[idx-1]) is not None: + # There is a "h", "m", or "s" preceeding this token. Since neither + # of the previous cases was hit, there is no label following this + # token, so we use the previous label. + # e.g. the "04" in "12h04" + hms_idx = idx-1 + + elif (1 < idx == len_l-1 and tokens[idx-1] == ' ' and + info.hms(tokens[idx-2]) is not None): + # If we are looking at the final token, we allow for a + # backward-looking check to skip over a space. + # TODO: Are we sure this is the right condition here? + hms_idx = idx - 2 + + else: + hms_idx = None + + return hms_idx + + def _assign_hms(self, res, value_repr, hms): + # See GH issue #427, fixing float rounding + value = self._to_decimal(value_repr) + + if hms == 0: + # Hour + res.hour = int(value) + if value % 1: + res.minute = int(60*(value % 1)) + + elif hms == 1: + (res.minute, res.second) = self._parse_min_sec(value) + + elif hms == 2: + (res.second, res.microsecond) = self._parsems(value_repr) + + def _could_be_tzname(self, hour, tzname, tzoffset, token): + return (hour is not None and + tzname is None and + tzoffset is None and + len(token) <= 5 and + all(x in string.ascii_uppercase for x in token)) + + def _ampm_valid(self, hour, ampm, fuzzy): + """ + For fuzzy parsing, 'a' or 'am' (both valid English words) + may erroneously trigger the AM/PM flag. Deal with that + here. + """ + val_is_ampm = True + + # If there's already an AM/PM flag, this one isn't one. + if fuzzy and ampm is not None: + val_is_ampm = False + + # If AM/PM is found and hour is not, raise a ValueError + if hour is None: + if fuzzy: + val_is_ampm = False + else: + raise ValueError('No hour specified with AM or PM flag.') + elif not 0 <= hour <= 12: + # If AM/PM is found, it's a 12 hour clock, so raise + # an error for invalid range + if fuzzy: + val_is_ampm = False + else: + raise ValueError('Invalid hour specified for 12-hour clock.') + + return val_is_ampm + + def _adjust_ampm(self, hour, ampm): + if hour < 12 and ampm == 1: + hour += 12 + elif hour == 12 and ampm == 0: + hour = 0 + return hour + + def _parse_min_sec(self, value): + # TODO: Every usage of this function sets res.second to the return + # value. Are there any cases where second will be returned as None and + # we *dont* want to set res.second = None? + minute = int(value) + second = None + + sec_remainder = value % 1 + if sec_remainder: + second = int(60 * sec_remainder) + return (minute, second) + + def _parsems(self, value): + """Parse a I[.F] seconds value into (seconds, microseconds).""" + if "." not in value: + return int(value), 0 + else: + i, f = value.split(".") + return int(i), int(f.ljust(6, "0")[:6]) + + def _parse_hms(self, idx, tokens, info, hms_idx): + # TODO: Is this going to admit a lot of false-positives for when we + # just happen to have digits and "h", "m" or "s" characters in non-date + # text? I guess hex hashes won't have that problem, but there's plenty + # of random junk out there. + if hms_idx is None: + hms = None + new_idx = idx + elif hms_idx > idx: + hms = info.hms(tokens[hms_idx]) + new_idx = hms_idx + else: + # Looking backwards, increment one. + hms = info.hms(tokens[hms_idx]) + 1 + new_idx = idx + + return (new_idx, hms) + + def _recombine_skipped(self, tokens, skipped_idxs): + """ + >>> tokens = ["foo", " ", "bar", " ", "19June2000", "baz"] + >>> skipped_idxs = [0, 1, 2, 5] + >>> _recombine_skipped(tokens, skipped_idxs) + ["foo bar", "baz"] + """ + skipped_tokens = [] + for i, idx in enumerate(sorted(skipped_idxs)): + if i > 0 and idx - 1 == skipped_idxs[i - 1]: + skipped_tokens[-1] = skipped_tokens[-1] + tokens[idx] + else: + skipped_tokens.append(tokens[idx]) + + return skipped_tokens + + def _build_tzinfo(self, tzinfos, tzname, tzoffset): + if callable(tzinfos): + tzdata = tzinfos(tzname, tzoffset) + else: + tzdata = tzinfos.get(tzname) + + if isinstance(tzdata, datetime.tzinfo): + tzinfo = tzdata + elif isinstance(tzdata, text_type): + tzinfo = tz.tzstr(tzdata) + elif isinstance(tzdata, integer_types): + tzinfo = tz.tzoffset(tzname, tzdata) + else: + raise ValueError("Offset must be tzinfo subclass, " + "tz string, or int offset.") + return tzinfo + + def _build_tzaware(self, naive, res, tzinfos): + if (callable(tzinfos) or (tzinfos and res.tzname in tzinfos)): + tzinfo = self._build_tzinfo(tzinfos, res.tzname, res.tzoffset) + aware = naive.replace(tzinfo=tzinfo) + aware = self._assign_tzname(aware, res.tzname) + + elif res.tzname and res.tzname in time.tzname: + aware = naive.replace(tzinfo=tz.tzlocal()) + + # Handle ambiguous local datetime + aware = self._assign_tzname(aware, res.tzname) + + # This is mostly relevant for winter GMT zones parsed in the UK + if (aware.tzname() != res.tzname and + res.tzname in self.info.UTCZONE): + aware = aware.replace(tzinfo=tz.tzutc()) + + elif res.tzoffset == 0: + aware = naive.replace(tzinfo=tz.tzutc()) + + elif res.tzoffset: + aware = naive.replace(tzinfo=tz.tzoffset(res.tzname, res.tzoffset)) + + elif not res.tzname and not res.tzoffset: + # i.e. no timezone information was found. + aware = naive + + elif res.tzname: + # tz-like string was parsed but we don't know what to do + # with it + warnings.warn("tzname {tzname} identified but not understood. " + "Pass `tzinfos` argument in order to correctly " + "return a timezone-aware datetime. In a future " + "version, this raise an " + "exception.".format(tzname=res.tzname), + category=UnknownTimezoneWarning) + aware = naive + + return aware + + def _build_naive(self, res, default): + repl = {} + for attr in ("year", "month", "day", "hour", + "minute", "second", "microsecond"): + value = getattr(res, attr) + if value is not None: + repl[attr] = value + + if 'day' not in repl: + # If the default day exceeds the last day of the month, fall back + # to the end of the month. + cyear = default.year if res.year is None else res.year + cmonth = default.month if res.month is None else res.month + cday = default.day if res.day is None else res.day + + if cday > monthrange(cyear, cmonth)[1]: + repl['day'] = monthrange(cyear, cmonth)[1] + + naive = default.replace(**repl) + + if res.weekday is not None and not res.day: + naive = naive + relativedelta.relativedelta(weekday=res.weekday) + + return naive + + def _assign_tzname(self, dt, tzname): + if dt.tzname() != tzname: + new_dt = tz.enfold(dt, fold=1) + if new_dt.tzname() == tzname: + return new_dt + + return dt + + def _to_decimal(self, val): + try: + return Decimal(val) + except Exception as e: + msg = "Could not convert %s to decimal" % val + six.raise_from(ValueError(msg), e) + DEFAULTPARSER = parser() @@ -1002,29 +1237,29 @@ def parse(timestr, parserinfo=None, **kwargs): :class:`datetime` object is returned. :param tzinfos: - Additional time zone names / aliases which may be present in the - string. This argument maps time zone names (and optionally offsets - from those time zones) to time zones. This parameter can be a - dictionary with timezone aliases mapping time zone names to time - zones or a function taking two parameters (``tzname`` and - ``tzoffset``) and returning a time zone. + Additional time zone names / aliases which may be present in the + string. This argument maps time zone names (and optionally offsets + from those time zones) to time zones. This parameter can be a + dictionary with timezone aliases mapping time zone names to time + zones or a function taking two parameters (``tzname`` and + ``tzoffset``) and returning a time zone. - The timezones to which the names are mapped can be an integer - offset from UTC in minutes or a :class:`tzinfo` object. + The timezones to which the names are mapped can be an integer + offset from UTC in seconds or a :class:`tzinfo` object. - .. doctest:: - :options: +NORMALIZE_WHITESPACE + .. doctest:: + :options: +NORMALIZE_WHITESPACE - >>> from dateutil.parser import parse - >>> from dateutil.tz import gettz - >>> tzinfos = {"BRST": -10800, "CST": gettz("America/Chicago")} - >>> parse("2012-01-19 17:21:00 BRST", tzinfos=tzinfos) - datetime.datetime(2012, 1, 19, 17, 21, tzinfo=tzoffset(u'BRST', -10800)) - >>> parse("2012-01-19 17:21:00 CST", tzinfos=tzinfos) - datetime.datetime(2012, 1, 19, 17, 21, - tzinfo=tzfile('/usr/share/zoneinfo/America/Chicago')) + >>> from dateutil.parser import parse + >>> from dateutil.tz import gettz + >>> tzinfos = {"BRST": -7200, "CST": gettz("America/Chicago")} + >>> parse("2012-01-19 17:21:00 BRST", tzinfos=tzinfos) + datetime.datetime(2012, 1, 19, 17, 21, tzinfo=tzoffset(u'BRST', -7200)) + >>> parse("2012-01-19 17:21:00 CST", tzinfos=tzinfos) + datetime.datetime(2012, 1, 19, 17, 21, + tzinfo=tzfile('/usr/share/zoneinfo/America/Chicago')) - This parameter is ignored if ``ignoretz`` is set. + This parameter is ignored if ``ignoretz`` is set. :param dayfirst: Whether to interpret the first value in an ambiguous 3-integer date @@ -1098,7 +1333,8 @@ class _tzparser(object): def parse(self, tzstr): res = self._result() - l = _timelex.split(tzstr) + l = [x for x in re.split(r'([,:.]|[a-zA-Z]+|[0-9]+)',tzstr) if x] + used_idxs = list() try: len_l = len(l) @@ -1117,6 +1353,9 @@ class _tzparser(object): else: offattr = "dstoffset" res.dstabbr = "".join(l[i:j]) + + for ii in range(j): + used_idxs.append(ii) i = j if (i < len_l and (l[i] in ('+', '-') or l[i][0] in "0123456789")): @@ -1124,6 +1363,7 @@ class _tzparser(object): # Yes, that's right. See the TZ variable # documentation. signal = (1, -1)[l[i] == '+'] + used_idxs.append(i) i += 1 else: signal = -1 @@ -1137,6 +1377,7 @@ class _tzparser(object): setattr(res, offattr, (int(l[i]) * 3600 + int(l[i + 2]) * 60) * signal) + used_idxs.append(i) i += 2 elif len_li <= 2: # -[0]3 @@ -1144,12 +1385,14 @@ class _tzparser(object): int(l[i][:2]) * 3600 * signal) else: return None + used_idxs.append(i) i += 1 if res.dstabbr: break else: break + if i < len_l: for j in range(i, len_l): if l[j] == ';': @@ -1163,32 +1406,47 @@ class _tzparser(object): pass elif (8 <= l.count(',') <= 9 and not [y for x in l[i:] if x != ',' - for y in x if y not in "0123456789"]): + for y in x if y not in "0123456789+-"]): # GMT0BST,3,0,30,3600,10,0,26,7200[,3600] for x in (res.start, res.end): x.month = int(l[i]) + used_idxs.append(i) i += 2 if l[i] == '-': value = int(l[i + 1]) * -1 + used_idxs.append(i) i += 1 else: value = int(l[i]) + used_idxs.append(i) i += 2 if value: x.week = value x.weekday = (int(l[i]) - 1) % 7 else: x.day = int(l[i]) + used_idxs.append(i) i += 2 x.time = int(l[i]) + used_idxs.append(i) i += 2 if i < len_l: if l[i] in ('-', '+'): signal = (-1, 1)[l[i] == "+"] + used_idxs.append(i) i += 1 else: signal = 1 - res.dstoffset = (res.stdoffset + int(l[i])) * signal + used_idxs.append(i) + res.dstoffset = (res.stdoffset + int(l[i]) * signal) + + # This was a made-up format that is not in normal use + warn(('Parsed time zone "%s"' % tzstr) + + 'is in a non-standard dateutil-specific format, which ' + + 'is now deprecated; support for parsing this format ' + + 'will be removed in future versions. It is recommended ' + + 'that you switch to a standard format like the GNU ' + + 'TZ variable format.', tz.DeprecatedTzFormatWarning) elif (l.count(',') == 2 and l[i:].count('/') <= 2 and not [y for x in l[i:] if x not in (',', '/', 'J', 'M', '.', '-', ':') @@ -1196,29 +1454,37 @@ class _tzparser(object): for x in (res.start, res.end): if l[i] == 'J': # non-leap year day (1 based) + used_idxs.append(i) i += 1 x.jyday = int(l[i]) elif l[i] == 'M': # month[-.]week[-.]weekday + used_idxs.append(i) i += 1 x.month = int(l[i]) + used_idxs.append(i) i += 1 assert l[i] in ('-', '.') + used_idxs.append(i) i += 1 x.week = int(l[i]) if x.week == 5: x.week = -1 + used_idxs.append(i) i += 1 assert l[i] in ('-', '.') + used_idxs.append(i) i += 1 x.weekday = (int(l[i]) - 1) % 7 else: # year day (zero based) x.yday = int(l[i]) + 1 + used_idxs.append(i) i += 1 if i < len_l and l[i] == '/': + used_idxs.append(i) i += 1 # start time len_li = len(l[i]) @@ -1229,8 +1495,10 @@ class _tzparser(object): elif i + 1 < len_l and l[i + 1] == ':': # -03:00 x.time = int(l[i]) * 3600 + int(l[i + 2]) * 60 + used_idxs.append(i) i += 2 if i + 1 < len_l and l[i + 1] == ':': + used_idxs.append(i) i += 2 x.time += int(l[i]) elif len_li <= 2: @@ -1238,6 +1506,7 @@ class _tzparser(object): x.time = (int(l[i][:2]) * 3600) else: return None + used_idxs.append(i) i += 1 assert i == len_l or l[i] == ',' @@ -1249,6 +1518,8 @@ class _tzparser(object): except (IndexError, ValueError, AssertionError): return None + unused_idxs = set(range(len_l)).difference(used_idxs) + res.any_unused_tokens = not {l[n] for n in unused_idxs}.issubset({",",":"}) return res @@ -1258,189 +1529,6 @@ DEFAULTTZPARSER = _tzparser() def _parsetz(tzstr): return DEFAULTTZPARSER.parse(tzstr) - -class InvalidDatetimeError(ValueError): - pass - - -class InvalidDateError(InvalidDatetimeError): - pass - - -class InvalidTimeError(InvalidDatetimeError): - pass - - -def _find_hms_idx(idx, tokens, info, allow_jump): - len_l = len(tokens) - - if idx+1 < len_l and info.hms(tokens[idx+1]) is not None: - # There is an "h", "m", or "s" label following this token. We take - # assign the upcoming label to the current token. - # e.g. the "12" in 12h" - hms_idx = idx + 1 - - elif (allow_jump and idx+2 < len_l and tokens[idx+1] == ' ' and - info.hms(tokens[idx+2]) is not None): - # There is a space and then an "h", "m", or "s" label. - # e.g. the "12" in "12 h" - hms_idx = idx + 2 - - elif idx > 0 and info.hms(tokens[idx-1]) is not None: - # There is a "h", "m", or "s" preceeding this token. Since neither - # of the previous cases was hit, there is no label following this - # token, so we use the previous label. - # e.g. the "04" in "12h04" - hms_idx = idx-1 - - elif (1 < idx == len_l-1 and tokens[idx-1] == ' ' and - info.hms(tokens[idx-2]) is not None): - # If we are looking at the final token, we allow for a - # backward-looking check to skip over a space. - # TODO: Are we sure this is the right condition here? - hms_idx = idx - 2 - - else: - hms_idx = None - - return hms_idx - - -def _parse_hms(idx, tokens, info, hms_idx): - # TODO: Is this going to admit a lot of false-positives for when we - # just happen to have digits and "h", "m" or "s" characters in non-date - # text? I guess hex hashes won't have that problem, but there's plenty - # of random junk out there. - if hms_idx is None: - hms = None - new_idx = idx - elif hms_idx > idx: - hms = info.hms(tokens[hms_idx]) - new_idx = hms_idx - else: - # Looking backwards, increment one. - hms = info.hms(tokens[hms_idx]) + 1 - new_idx = idx - - return (new_idx, hms) - - -def _assign_hms(res, value_repr, hms): - value = float(value_repr) - if hms == 0: - # Hour - res.hour = int(value) - if value % 1: - res.minute = int(60*(value % 1)) - - elif hms == 1: - (res.minute, res.second) = _parse_min_sec(value) - - elif hms == 2: - (res.second, res.microsecond) = _parsems(value_repr) - - -def _could_be_tzname(hour, tzname, tzoffset, token): - return (hour is not None and - tzname is None and - tzoffset is None and - len(token) <= 5 and - all(x in string.ascii_uppercase for x in token)) - - -def _ampm_validity(hour, ampm, fuzzy): - """ - For fuzzy parsing, 'a' or 'am' (both valid English words) - may erroneously trigger the AM/PM flag. Deal with that - here. - """ - val_is_ampm = True - - # If there's already an AM/PM flag, this one isn't one. - if fuzzy and ampm is not None: - val_is_ampm = False - - # If AM/PM is found and hour is not, raise a ValueError - if hour is None: - if fuzzy: - val_is_ampm = False - else: - raise ValueError('No hour specified with AM or PM flag.') - elif not 0 <= hour <= 12: - # If AM/PM is found, it's a 12 hour clock, so raise - # an error for invalid range - if fuzzy: - val_is_ampm = False - else: - raise ValueError('Invalid hour specified for 12-hour clock.') - - return val_is_ampm - - -def _adjust_ampm(hour, ampm): - if hour < 12 and ampm == 1: - hour += 12 - elif hour == 12 and ampm == 0: - hour = 0 - return hour - - -def _parse_min_sec(value): - # TODO: Every usage of this function sets res.second to the return value. - # Are there any cases where second will be returned as None and we *dont* - # want to set res.second = None? - minute = int(value) - second = None - - sec_remainder = value % 1 - if sec_remainder: - second = int(60 * sec_remainder) - return (minute, second) - - -def _parsems(value): - """Parse a I[.F] seconds value into (seconds, microseconds).""" - if "." not in value: - return int(value), 0 - else: - i, f = value.split(".") - return int(i), int(f.ljust(6, "0")[:6]) - - -def _recombine_skipped(tokens, skipped_idxs): - """ - >>> tokens = ["foo", " ", "bar", " ", "19June2000", "baz"] - >>> skipped_idxs = [0, 1, 2, 5] - >>> _recombine_skipped(tokens, skipped_idxs) - ["foo bar", "baz"] - - """ - - skipped_tokens = [] - for i, idx in enumerate(sorted(skipped_idxs)): - if i > 0 and idx - 1 == skipped_idxs[i - 1]: - skipped_tokens[-1] = skipped_tokens[-1] + tokens[idx] - else: - skipped_tokens.append(tokens[idx]) - - return skipped_tokens - - -def _build_tzinfo(tzinfos, tzname, tzoffset): - if isinstance(tzinfos, collections.Callable): - tzdata = tzinfos(tzname, tzoffset) - else: - tzdata = tzinfos.get(tzname) - - if isinstance(tzdata, datetime.tzinfo): - tzinfo = tzdata - elif isinstance(tzdata, text_type): - tzinfo = tz.tzstr(tzdata) - elif isinstance(tzdata, integer_types): - tzinfo = tz.tzoffset(tzname, tzdata) - else: - raise ValueError("Offset must be tzinfo subclass, " - "tz string, or int offset.") - return tzinfo - +class UnknownTimezoneWarning(RuntimeWarning): + """Raised when the parser finds a timezone it cannot parse into a tzinfo""" # vim:ts=4:sw=4:et diff --git a/lib/dateutil/parser/isoparser.py b/lib/dateutil/parser/isoparser.py new file mode 100644 index 00000000..b08eb711 --- /dev/null +++ b/lib/dateutil/parser/isoparser.py @@ -0,0 +1,402 @@ +# -*- 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=None): + """ + :param sep: + A single character that separates date and time portions. If + ``None``, the parser will accept any single character. + For strict ISO-8601 adherence, pass ``'T'``. + """ + if sep is not None: + if (len(sep) != 1 or ord(sep) >= 128 or sep in '0123456789'): + raise ValueError('Separator must be a single, non-numeric ' + + 'ASCII character') + + sep = sep.encode('ascii') + + self._sep = sep + + @_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. Incomplete date formats (such as ``YYYY-MM``) may *not* be + combined with a time portion. + + 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. + + .. warning:: + + As of version 2.7.0, the strictness of the parser should not be + considered a stable part of the contract. Any valid ISO-8601 string + that parses correctly with the default settings will continue to + parse correctly in future versions, but invalid strings that + currently fail (e.g. ``2017-01-01T00:00+00:00:00``) are not + guaranteed to continue failing in future versions if they encode + a valid date. + """ + components, pos = self._parse_isodate(dt_str) + + if len(dt_str) > pos: + if self._sep is None or 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: + if has_sep: + return components, pos + else: + raise ValueError('Invalid ISO format') + + 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/lib/dateutil/relativedelta.py b/lib/dateutil/relativedelta.py index 0b7a1690..e4394707 100644 --- a/lib/dateutil/relativedelta.py +++ b/lib/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 `_ extension. + `mx.DateTime `_ 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 @@ -249,7 +249,7 @@ class relativedelta(object): @property def weeks(self): - return self.days // 7 + return int(self.days / 7.0) @weeks.setter def weeks(self, value): @@ -422,6 +422,24 @@ class relativedelta(object): is not None else other.microsecond)) + def __abs__(self): + return self.__class__(years=abs(self.years), + months=abs(self.months), + days=abs(self.days), + hours=abs(self.hours), + minutes=abs(self.minutes), + seconds=abs(self.seconds), + microseconds=abs(self.microseconds), + leapdays=self.leapdays, + year=self.year, + month=self.month, + day=self.day, + weekday=self.weekday, + hour=self.hour, + minute=self.minute, + second=self.second, + microsecond=self.microsecond) + def __neg__(self): return self.__class__(years=-self.years, months=-self.months, diff --git a/lib/dateutil/rrule.py b/lib/dateutil/rrule.py index d80d903c..ff66cd5b 100644 --- a/lib/dateutil/rrule.py +++ b/lib/dateutil/rrule.py @@ -2,12 +2,13 @@ """ The rrule module offers a small, complete, and very fast, implementation of the recurrence rules documented in the -`iCalendar RFC `_, +`iCalendar RFC `_, including support for caching of results. """ import itertools import datetime import calendar +import re import sys try: @@ -20,6 +21,7 @@ from six.moves import _thread, range import heapq from ._common import weekday as weekdaybase +from .tz import tzutc, tzlocal # For warning about deprecation of until and count from warnings import warn @@ -359,7 +361,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 +370,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 @@ -446,8 +448,22 @@ class rrule(rrulebase): until = datetime.datetime.fromordinal(until.toordinal()) self._until = until + if self._dtstart and self._until: + if (self._dtstart.tzinfo is not None) != (self._until.tzinfo is not None): + # According to RFC5545 Section 3.3.10: + # https://tools.ietf.org/html/rfc5545#section-3.3.10 + # + # > If the "DTSTART" property is specified as a date with UTC + # > time or a date with local time and time zone reference, + # > then the UNTIL rule part MUST be specified as a date with + # > UTC time. + raise ValueError( + 'RRULE UNTIL values must be specified in UTC when DTSTART ' + 'is timezone-aware' + ) + 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 +690,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 +715,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']: @@ -1496,11 +1512,17 @@ class _rrulestr(object): forceset=False, compatible=False, ignoretz=False, + tzids=None, tzinfos=None): global parser if compatible: forceset = True unfold = True + + TZID_NAMES = dict(map( + lambda x: (x.upper(), x), + re.findall('TZID=(?P[^:]+):', s) + )) s = s.upper() if not s.strip(): raise ValueError("empty string") @@ -1563,8 +1585,29 @@ class _rrulestr(object): # RFC 5445 3.8.2.4: The VALUE parameter is optional, but # may be found only once. value_found = False + TZID = None valid_values = {"VALUE=DATE-TIME", "VALUE=DATE"} for parm in parms: + if parm.startswith("TZID="): + try: + tzkey = TZID_NAMES[parm.split('TZID=')[-1]] + except KeyError: + continue + if tzids is None: + from . import tz + tzlookup = tz.gettz + elif callable(tzids): + tzlookup = tzids + else: + tzlookup = getattr(tzids, 'get', None) + if tzlookup is None: + msg = ('tzids must be a callable, ' + + 'mapping, or None, ' + + 'not %s' % tzids) + raise ValueError(msg) + + TZID = tzlookup(tzkey) + continue if parm not in valid_values: raise ValueError("unsupported DTSTART parm: "+parm) else: @@ -1577,6 +1620,11 @@ class _rrulestr(object): from dateutil import parser dtstart = parser.parse(value, ignoretz=ignoretz, tzinfos=tzinfos) + if TZID is not None: + if dtstart.tzinfo is None: + dtstart = dtstart.replace(tzinfo=TZID) + else: + raise ValueError('DTSTART specifies multiple timezones') else: raise ValueError("unsupported property: "+name) if (forceset or len(rrulevals) > 1 or rdatevals diff --git a/lib/dateutil/tz/__init__.py b/lib/dateutil/tz/__init__.py index 224f17d8..4212d07d 100644 --- a/lib/dateutil/tz/__init__.py +++ b/lib/dateutil/tz/__init__.py @@ -1,5 +1,15 @@ from .tz import * +#: Convenience constant providing a :class:`tzutc()` instance +#: +#: .. versionadded:: 2.7.0 +UTC = tzutc() + __all__ = ["tzutc", "tzoffset", "tzlocal", "tzfile", "tzrange", "tzstr", "tzical", "tzwin", "tzwinlocal", "gettz", - "enfold", "datetime_ambiguous", "datetime_exists"] + "enfold", "datetime_ambiguous", "datetime_exists", + "resolve_imaginary", "UTC", "DeprecatedTzFormatWarning"] + + +class DeprecatedTzFormatWarning(Warning): + """Warning raised when time zones are parsed from deprecated formats.""" diff --git a/lib/dateutil/tz/_factories.py b/lib/dateutil/tz/_factories.py new file mode 100644 index 00000000..88216f90 --- /dev/null +++ b/lib/dateutil/tz/_factories.py @@ -0,0 +1,49 @@ +from datetime import timedelta + + +class _TzSingleton(type): + def __init__(cls, *args, **kwargs): + cls.__instance = None + super(_TzSingleton, cls).__init__(*args, **kwargs) + + def __call__(cls): + if cls.__instance is None: + cls.__instance = super(_TzSingleton, cls).__call__() + return cls.__instance + +class _TzFactory(type): + def instance(cls, *args, **kwargs): + """Alternate constructor that returns a fresh instance""" + return type.__call__(cls, *args, **kwargs) + + +class _TzOffsetFactory(_TzFactory): + def __init__(cls, *args, **kwargs): + cls.__instances = {} + + def __call__(cls, name, offset): + if isinstance(offset, timedelta): + key = (name, offset.total_seconds()) + else: + key = (name, offset) + + instance = cls.__instances.get(key, None) + if instance is None: + instance = cls.__instances.setdefault(key, + cls.instance(name, offset)) + return instance + + +class _TzStrFactory(_TzFactory): + def __init__(cls, *args, **kwargs): + cls.__instances = {} + + def __call__(cls, s, posix_offset=False): + key = (s, posix_offset) + instance = cls.__instances.get(key, None) + + if instance is None: + instance = cls.__instances.setdefault(key, + cls.instance(s, posix_offset)) + return instance + diff --git a/lib/dateutil/tz/tz.py b/lib/dateutil/tz/tz.py index 5020838b..0a8016ac 100644 --- a/lib/dateutil/tz/tz.py +++ b/lib/dateutil/tz/tz.py @@ -14,12 +14,15 @@ import sys import os import bisect +import six from six import string_types from six.moves import _thread from ._common import tzname_in_python2, _tzinfo from ._common import tzrangebase, enfold from ._common import _validate_fromutc_inputs +from ._factories import _TzSingleton, _TzOffsetFactory +from ._factories import _TzStrFactory try: from .win import tzwin, tzwinlocal except ImportError: @@ -30,9 +33,38 @@ EPOCH = datetime.datetime.utcfromtimestamp(0) EPOCHORDINAL = EPOCH.toordinal() +@six.add_metaclass(_TzSingleton) 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. + + .. doctest:: + + >>> from dateutil.tz import tzutc, UTC + >>> tzutc() is tzutc() + True + >>> tzutc() is UTC + True """ def utcoffset(self, dt): return ZERO @@ -86,16 +118,16 @@ class tzutc(datetime.tzinfo): __reduce__ = object.__reduce__ +@six.add_metaclass(_TzOffsetFactory) 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: The time zone offset in seconds, or (since version 2.6.0, represented - as a :py:class:`datetime.timedelta` object. + as a :py:class:`datetime.timedelta` object). """ def __init__(self, name, offset): self._name = name @@ -128,8 +160,6 @@ class tzoffset(datetime.tzinfo): :param dt: A :py:class:`datetime.datetime`, naive or time zone aware. - - :return: Returns ``True`` if ambiguous, ``False`` otherwise. @@ -171,6 +201,7 @@ class tzlocal(_tzinfo): self._dst_saved = self._dst_offset - self._std_offset self._hasdst = bool(self._dst_saved) + self._tznames = tuple(time.tzname) def utcoffset(self, dt): if dt is None and self._hasdst: @@ -192,7 +223,7 @@ class tzlocal(_tzinfo): @tzname_in_python2 def tzname(self, dt): - return time.tzname[self._isdst(dt)] + return self._tznames[self._isdst(dt)] def is_ambiguous(self, dt): """ @@ -257,12 +288,20 @@ class tzlocal(_tzinfo): return dstval def __eq__(self, other): - if not isinstance(other, tzlocal): + if isinstance(other, tzlocal): + return (self._std_offset == other._std_offset and + self._dst_offset == other._dst_offset) + elif isinstance(other, tzutc): + return (not self._hasdst and + self._tznames[0] in {'UTC', 'GMT'} and + self._std_offset == ZERO) + elif isinstance(other, tzoffset): + return (not self._hasdst and + self._tznames[0] == other._name and + self._std_offset == other._offset) + else: return NotImplemented - return (self._std_offset == other._std_offset and - self._dst_offset == other._dst_offset) - __hash__ = None def __ne__(self, other): @@ -348,8 +387,8 @@ class tzfile(_tzinfo): ``fileobj``'s ``name`` attribute or to ``repr(fileobj)``. See `Sources for Time Zone and Daylight Saving Time Data - `_ for more information. Time zone - files can be compiled from the `IANA Time Zone database files + `_ for more information. Time + zone files can be compiled from the `IANA Time Zone database files `_ with the `zic time zone compiler `_ """ @@ -927,6 +966,7 @@ class tzrange(tzrangebase): return self._dst_base_offset_ +@six.add_metaclass(_TzStrFactory) class tzstr(tzrange): """ ``tzstr`` objects are time zone objects specified by a time-zone string as @@ -953,17 +993,29 @@ class tzstr(tzrange): ``UTC+3`` as being 3 hours *behind* UTC rather than ahead, per the POSIX standard. + .. caution:: + + Prior to version 2.7.0, this function also supported time zones + in the format: + + * ``EST5EDT,4,0,6,7200,10,0,26,7200,3600`` + * ``EST5EDT,4,1,0,7200,10,-1,0,7200,3600`` + + This format is non-standard and has been deprecated; this function + will raise a :class:`DeprecatedTZFormatWarning` until + support is removed in a future version. + .. _`GNU C Library: TZ Variable`: https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html """ def __init__(self, s, posix_offset=False): global parser - from dateutil import parser + from dateutil.parser import _parser as parser self._s = s res = parser._parsetz(s) - if res is None: + if res is None or res.any_unused_tokens: raise ValueError("unknown string format") # Here we break the compatibility with the TZ variable handling. @@ -1133,13 +1185,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 @@ -1346,84 +1398,118 @@ else: TZFILES = [] TZPATHS = [] +def __get_gettz(name, zoneinfo_priority=False): + tzlocal_classes = (tzlocal,) + if tzwinlocal is not None: + tzlocal_classes += (tzwinlocal,) -def gettz(name=None, zoneinfo_priority=False): - tz = None - if not name: - try: - name = os.environ["TZ"] - except KeyError: - pass - if name is None or name == ":": - for filepath in TZFILES: - if not os.path.isabs(filepath): - filename = filepath - for path in TZPATHS: - filepath = os.path.join(path, filename) - if os.path.isfile(filepath): - break - else: - continue - if os.path.isfile(filepath): + class GettzFunc(object): + def __init__(self, name, zoneinfo_priority=False): + + self.__instances = {} + self._cache_lock = _thread.allocate_lock() + + def __call__(self, name=None, zoneinfo_priority=False): + with self._cache_lock: + rv = self.__instances.get(name, None) + + if rv is None: + rv = self.nocache(name=name, zoneinfo_priority=zoneinfo_priority) + if not (name is None or isinstance(rv, tzlocal_classes)): + # tzlocal is slightly more complicated than the other + # time zone providers because it depends on environment + # at construction time, so don't cache that. + self.__instances[name] = rv + + return rv + + def cache_clear(self): + with self._cache_lock: + self.__instances = {} + + @staticmethod + def nocache(name=None, zoneinfo_priority=False): + """A non-cached version of gettz""" + tz = None + if not name: try: - tz = tzfile(filepath) - break - except (IOError, OSError, ValueError): + name = os.environ["TZ"] + except KeyError: pass - else: - tz = tzlocal() - else: - if name.startswith(":"): - name = name[:-1] - if os.path.isabs(name): - if os.path.isfile(name): - tz = tzfile(name) - else: - tz = None - else: - if zoneinfo_priority: - from dateutil.zoneinfo import get_zonefile_instance - tz = get_zonefile_instance().get(name) - if not tz: - for path in TZPATHS: - filepath = os.path.join(path, name) - if not os.path.isfile(filepath): - filepath = filepath.replace(' ', '_') - if not os.path.isfile(filepath): - continue - try: - tz = tzfile(filepath) - break - except (IOError, OSError, ValueError): - pass - else: - tz = None - if tzwin is not None: - try: - tz = tzwin(name) - except WindowsError: - tz = None - - if not zoneinfo_priority and not tz: - from dateutil.zoneinfo import get_zonefile_instance - tz = get_zonefile_instance().get(name) - - if not tz: - for c in name: - # name must have at least one offset to be a tzstr - if c in "0123456789": - try: - tz = tzstr(name) - except ValueError: - pass + if name is None or name == ":": + for filepath in TZFILES: + if not os.path.isabs(filepath): + filename = filepath + for path in TZPATHS: + filepath = os.path.join(path, filename) + if os.path.isfile(filepath): break else: - if name in ("GMT", "UTC"): - tz = tzutc() - elif name in time.tzname: - tz = tzlocal() - return tz + continue + if os.path.isfile(filepath): + try: + tz = tzfile(filepath) + break + except (IOError, OSError, ValueError): + pass + else: + tz = tzlocal() + else: + if name.startswith(":"): + name = name[1:] + if os.path.isabs(name): + if os.path.isfile(name): + tz = tzfile(name) + else: + tz = None + else: + if zoneinfo_priority: + from dateutil.zoneinfo import get_zonefile_instance + tz = get_zonefile_instance().get(name) + if not tz: + for path in TZPATHS: + filepath = os.path.join(path, name) + if not os.path.isfile(filepath): + filepath = filepath.replace(' ', '_') + if not os.path.isfile(filepath): + continue + try: + tz = tzfile(filepath) + break + except (IOError, OSError, ValueError): + pass + else: + tz = None + if tzwin is not None: + try: + tz = tzwin(name) + except WindowsError: + tz = None + if not zoneinfo_priority and not tz: + from dateutil.zoneinfo import get_zonefile_instance + tz = get_zonefile_instance().get(name) + + if not tz: + for c in name: + # name must have at least one offset to be a tzstr + if c in "0123456789": + try: + tz = tzstr(name) + except ValueError: + pass + break + else: + if name in ("GMT", "UTC"): + tz = tzutc() + elif name in time.tzname: + tz = tzlocal() + return tz + + return GettzFunc(name, zoneinfo_priority) + +gettz = __get_gettz(name=None, zoneinfo_priority=False) +del __get_gettz def datetime_exists(dt, tz=None): """ @@ -1440,6 +1526,8 @@ def datetime_exists(dt, tz=None): :return: Returns a boolean value whether or not the "wall time" exists in ``tz``. + + ..versionadded:: 2.7.0 """ if tz is None: if dt.tzinfo is None: @@ -1502,6 +1590,51 @@ def datetime_ambiguous(dt, tz=None): return not (same_offset and same_dst) +def resolve_imaginary(dt): + """ + Given a datetime that may be imaginary, return an existing datetime. + + This function assumes that an imaginary datetime represents what the + wall time would be in a zone had the offset transition not occurred, so + it will always fall forward by the transition's change in offset. + + ..doctest:: + >>> from dateutil import tz + >>> from datetime import datetime + >>> NYC = tz.gettz('America/New_York') + >>> print(tz.resolve_imaginary(datetime(2017, 3, 12, 2, 30, tzinfo=NYC))) + 2017-03-12 03:30:00-04:00 + + >>> KIR = tz.gettz('Pacific/Kiritimati') + >>> print(tz.resolve_imaginary(datetime(1995, 1, 1, 12, 30, tzinfo=KIR))) + 1995-01-02 12:30:00+14:00 + + As a note, :func:`datetime.astimezone` is guaranteed to produce a valid, + existing datetime, so a round-trip to and from UTC is sufficient to get + an extant datetime, however, this generally "falls back" to an earlier time + rather than falling forward to the STD side (though no guarantees are made + about this behavior). + + :param dt: + A :class:`datetime.datetime` which may or may not exist. + + :return: + Returns an existing :class:`datetime.datetime`. If ``dt`` was not + imaginary, the datetime returned is guaranteed to be the same object + passed to the function. + + ..versionadded:: 2.7.0 + """ + if dt.tzinfo is not None and not datetime_exists(dt): + + curr_offset = (dt + datetime.timedelta(hours=24)).utcoffset() + old_offset = (dt - datetime.timedelta(hours=24)).utcoffset() + + dt += curr_offset - old_offset + + return dt + + def _datetime_to_timestamp(dt): """ Convert a :class:`datetime.datetime` object to an epoch timestamp in seconds diff --git a/lib/dateutil/utils.py b/lib/dateutil/utils.py index 2b37060f..8fc34bd5 100644 --- a/lib/dateutil/utils.py +++ b/lib/dateutil/utils.py @@ -1,6 +1,59 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from datetime import datetime, time + + +def today(tzinfo=None): + """ + Returns a :py:class:`datetime` representing the current day at midnight + + :param tzinfo: + The time zone to attach (also used to determine the current day). + + :return: + A :py:class:`datetime.datetime` object representing the current day + at midnight. + """ + + dt = datetime.now(tzinfo) + return datetime.combine(dt.date(), time(0, tzinfo=tzinfo)) + + +def default_tzinfo(dt, tzinfo): + """ + Sets the the ``tzinfo`` parameter on naive datetimes only + + This is useful for example when you are provided a datetime that may have + either an implicit or explicit time zone, such as when parsing a time zone + string. + + .. doctest:: + + >>> from dateutil.tz import tzoffset + >>> from dateutil.parser import parse + >>> from dateutil.utils import default_tzinfo + >>> dflt_tz = tzoffset("EST", -18000) + >>> print(default_tzinfo(parse('2014-01-01 12:30 UTC'), dflt_tz)) + 2014-01-01 12:30:00+00:00 + >>> print(default_tzinfo(parse('2014-01-01 12:30'), dflt_tz)) + 2014-01-01 12:30:00-05:00 + + :param dt: + The datetime on which to replace the time zone + + :param tzinfo: + The :py:class:`datetime.tzinfo` subclass instance to assign to + ``dt`` if (and only if) it is naive. + + :return: + Returns an aware :py:class:`datetime.datetime`. + """ + if dt.tzinfo is not None: + return dt + else: + return dt.replace(tzinfo=tzinfo) + def within_delta(dt1, dt2, delta): """ diff --git a/lib/dateutil/zoneinfo/__init__.py b/lib/dateutil/zoneinfo/__init__.py index 9d2ebf3a..a5d5274f 100644 --- a/lib/dateutil/zoneinfo/__init__.py +++ b/lib/dateutil/zoneinfo/__init__.py @@ -6,20 +6,19 @@ import os from tarfile import TarFile from pkgutil import get_data from io import BytesIO -from contextlib import closing -from dateutil.tz import tzfile +from dateutil.tz import tzfile as _tzfile from sickbeard import encodingKludge as ek import sickbeard -__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' -class tzfile(tzfile): +class tzfile(_tzfile): def __reduce__(self): return (gettz, (self._filename,)) diff --git a/lib/dateutil/zoneinfo/rebuild.py b/lib/dateutil/zoneinfo/rebuild.py index 241e25e9..d43d1648 100644 --- a/lib/dateutil/zoneinfo/rebuild.py +++ b/lib/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() diff --git a/sickbeard/network_timezones.py b/sickbeard/network_timezones.py index 0972b311..a3043c5c 100644 --- a/sickbeard/network_timezones.py +++ b/sickbeard/network_timezones.py @@ -189,6 +189,7 @@ def _update_zoneinfo(): from dateutil.zoneinfo import gettz if '_CLASS_ZONE_INSTANCE' in gettz.func_globals: gettz.func_globals.__setitem__('_CLASS_ZONE_INSTANCE', list()) + tz.gettz.cache_clear() sb_timezone = get_tz() except: