diff options
author | morpheus65535 <[email protected]> | 2022-09-22 06:33:33 -0400 |
---|---|---|
committer | morpheus65535 <[email protected]> | 2022-09-22 06:33:33 -0400 |
commit | a338de147e8a4d74ca266b1306997fcfc90b8941 (patch) | |
tree | 6970a11c1c6126677f00e153107c2812a37f069a /libs/aniso8601 | |
parent | 131b4e5cde4034f78923d7eaebd49b3550f8aa13 (diff) | |
download | bazarr-a338de147e8a4d74ca266b1306997fcfc90b8941.tar.gz bazarr-a338de147e8a4d74ca266b1306997fcfc90b8941.zip |
Fixed import error after last commit.v1.1.2-beta.8
Diffstat (limited to 'libs/aniso8601')
27 files changed, 9368 insertions, 0 deletions
diff --git a/libs/aniso8601/__init__.py b/libs/aniso8601/__init__.py new file mode 100644 index 000000000..033d30b9d --- /dev/null +++ b/libs/aniso8601/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +from aniso8601.date import get_date_resolution, parse_date +from aniso8601.duration import get_duration_resolution, parse_duration +from aniso8601.interval import ( + get_interval_resolution, + get_repeating_interval_resolution, + parse_interval, + parse_repeating_interval, +) + +# Import the main parsing functions so they are readily available +from aniso8601.time import ( + get_datetime_resolution, + get_time_resolution, + parse_datetime, + parse_time, +) + +__version__ = "9.0.1" diff --git a/libs/aniso8601/builders/__init__.py b/libs/aniso8601/builders/__init__.py new file mode 100644 index 000000000..834c72a6b --- /dev/null +++ b/libs/aniso8601/builders/__init__.py @@ -0,0 +1,614 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import calendar +from collections import namedtuple + +from aniso8601.exceptions import ( + DayOutOfBoundsError, + HoursOutOfBoundsError, + ISOFormatError, + LeapSecondError, + MidnightBoundsError, + MinutesOutOfBoundsError, + MonthOutOfBoundsError, + SecondsOutOfBoundsError, + WeekOutOfBoundsError, + YearOutOfBoundsError, +) + +DateTuple = namedtuple("Date", ["YYYY", "MM", "DD", "Www", "D", "DDD"]) +TimeTuple = namedtuple("Time", ["hh", "mm", "ss", "tz"]) +DatetimeTuple = namedtuple("Datetime", ["date", "time"]) +DurationTuple = namedtuple( + "Duration", ["PnY", "PnM", "PnW", "PnD", "TnH", "TnM", "TnS"] +) +IntervalTuple = namedtuple("Interval", ["start", "end", "duration"]) +RepeatingIntervalTuple = namedtuple("RepeatingInterval", ["R", "Rnn", "interval"]) +TimezoneTuple = namedtuple("Timezone", ["negative", "Z", "hh", "mm", "name"]) + +Limit = namedtuple( + "Limit", + [ + "casterrorstring", + "min", + "max", + "rangeexception", + "rangeerrorstring", + "rangefunc", + ], +) + + +def cast( + value, + castfunction, + caughtexceptions=(ValueError,), + thrownexception=ISOFormatError, + thrownmessage=None, +): + try: + result = castfunction(value) + except caughtexceptions: + raise thrownexception(thrownmessage) + + return result + + +def range_check(valuestr, limit): + # Returns cast value if in range, raises defined exceptions on failure + if valuestr is None: + return None + + if "." in valuestr: + castfunc = float + else: + castfunc = int + + value = cast(valuestr, castfunc, thrownmessage=limit.casterrorstring) + + if limit.min is not None and value < limit.min: + raise limit.rangeexception(limit.rangeerrorstring) + + if limit.max is not None and value > limit.max: + raise limit.rangeexception(limit.rangeerrorstring) + + return value + + +class BaseTimeBuilder(object): + # Limit tuple format cast function, cast error string, + # lower limit, upper limit, limit error string + DATE_YYYY_LIMIT = Limit( + "Invalid year string.", + 0000, + 9999, + YearOutOfBoundsError, + "Year must be between 1..9999.", + range_check, + ) + DATE_MM_LIMIT = Limit( + "Invalid month string.", + 1, + 12, + MonthOutOfBoundsError, + "Month must be between 1..12.", + range_check, + ) + DATE_DD_LIMIT = Limit( + "Invalid day string.", + 1, + 31, + DayOutOfBoundsError, + "Day must be between 1..31.", + range_check, + ) + DATE_WWW_LIMIT = Limit( + "Invalid week string.", + 1, + 53, + WeekOutOfBoundsError, + "Week number must be between 1..53.", + range_check, + ) + DATE_D_LIMIT = Limit( + "Invalid weekday string.", + 1, + 7, + DayOutOfBoundsError, + "Weekday number must be between 1..7.", + range_check, + ) + DATE_DDD_LIMIT = Limit( + "Invalid ordinal day string.", + 1, + 366, + DayOutOfBoundsError, + "Ordinal day must be between 1..366.", + range_check, + ) + TIME_HH_LIMIT = Limit( + "Invalid hour string.", + 0, + 24, + HoursOutOfBoundsError, + "Hour must be between 0..24 with " "24 representing midnight.", + range_check, + ) + TIME_MM_LIMIT = Limit( + "Invalid minute string.", + 0, + 59, + MinutesOutOfBoundsError, + "Minute must be between 0..59.", + range_check, + ) + TIME_SS_LIMIT = Limit( + "Invalid second string.", + 0, + 60, + SecondsOutOfBoundsError, + "Second must be between 0..60 with " "60 representing a leap second.", + range_check, + ) + TZ_HH_LIMIT = Limit( + "Invalid timezone hour string.", + 0, + 23, + HoursOutOfBoundsError, + "Hour must be between 0..23.", + range_check, + ) + TZ_MM_LIMIT = Limit( + "Invalid timezone minute string.", + 0, + 59, + MinutesOutOfBoundsError, + "Minute must be between 0..59.", + range_check, + ) + DURATION_PNY_LIMIT = Limit( + "Invalid year duration string.", + 0, + None, + ISOFormatError, + "Duration years component must be positive.", + range_check, + ) + DURATION_PNM_LIMIT = Limit( + "Invalid month duration string.", + 0, + None, + ISOFormatError, + "Duration months component must be positive.", + range_check, + ) + DURATION_PNW_LIMIT = Limit( + "Invalid week duration string.", + 0, + None, + ISOFormatError, + "Duration weeks component must be positive.", + range_check, + ) + DURATION_PND_LIMIT = Limit( + "Invalid day duration string.", + 0, + None, + ISOFormatError, + "Duration days component must be positive.", + range_check, + ) + DURATION_TNH_LIMIT = Limit( + "Invalid hour duration string.", + 0, + None, + ISOFormatError, + "Duration hours component must be positive.", + range_check, + ) + DURATION_TNM_LIMIT = Limit( + "Invalid minute duration string.", + 0, + None, + ISOFormatError, + "Duration minutes component must be positive.", + range_check, + ) + DURATION_TNS_LIMIT = Limit( + "Invalid second duration string.", + 0, + None, + ISOFormatError, + "Duration seconds component must be positive.", + range_check, + ) + INTERVAL_RNN_LIMIT = Limit( + "Invalid duration repetition string.", + 0, + None, + ISOFormatError, + "Duration repetition count must be positive.", + range_check, + ) + + DATE_RANGE_DICT = { + "YYYY": DATE_YYYY_LIMIT, + "MM": DATE_MM_LIMIT, + "DD": DATE_DD_LIMIT, + "Www": DATE_WWW_LIMIT, + "D": DATE_D_LIMIT, + "DDD": DATE_DDD_LIMIT, + } + + TIME_RANGE_DICT = {"hh": TIME_HH_LIMIT, "mm": TIME_MM_LIMIT, "ss": TIME_SS_LIMIT} + + DURATION_RANGE_DICT = { + "PnY": DURATION_PNY_LIMIT, + "PnM": DURATION_PNM_LIMIT, + "PnW": DURATION_PNW_LIMIT, + "PnD": DURATION_PND_LIMIT, + "TnH": DURATION_TNH_LIMIT, + "TnM": DURATION_TNM_LIMIT, + "TnS": DURATION_TNS_LIMIT, + } + + REPEATING_INTERVAL_RANGE_DICT = {"Rnn": INTERVAL_RNN_LIMIT} + + TIMEZONE_RANGE_DICT = {"hh": TZ_HH_LIMIT, "mm": TZ_MM_LIMIT} + + LEAP_SECONDS_SUPPORTED = False + + @classmethod + def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None): + raise NotImplementedError + + @classmethod + def build_time(cls, hh=None, mm=None, ss=None, tz=None): + raise NotImplementedError + + @classmethod + def build_datetime(cls, date, time): + raise NotImplementedError + + @classmethod + def build_duration( + cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None + ): + raise NotImplementedError + + @classmethod + def build_interval(cls, start=None, end=None, duration=None): + # start, end, and duration are all tuples + raise NotImplementedError + + @classmethod + def build_repeating_interval(cls, R=None, Rnn=None, interval=None): + # interval is a tuple + raise NotImplementedError + + @classmethod + def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""): + raise NotImplementedError + + @classmethod + def range_check_date( + cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None, rangedict=None + ): + if rangedict is None: + rangedict = cls.DATE_RANGE_DICT + + if "YYYY" in rangedict: + YYYY = rangedict["YYYY"].rangefunc(YYYY, rangedict["YYYY"]) + + if "MM" in rangedict: + MM = rangedict["MM"].rangefunc(MM, rangedict["MM"]) + + if "DD" in rangedict: + DD = rangedict["DD"].rangefunc(DD, rangedict["DD"]) + + if "Www" in rangedict: + Www = rangedict["Www"].rangefunc(Www, rangedict["Www"]) + + if "D" in rangedict: + D = rangedict["D"].rangefunc(D, rangedict["D"]) + + if "DDD" in rangedict: + DDD = rangedict["DDD"].rangefunc(DDD, rangedict["DDD"]) + + if DD is not None: + # Check calendar + if DD > calendar.monthrange(YYYY, MM)[1]: + raise DayOutOfBoundsError( + "{0} is out of range for {1}-{2}".format(DD, YYYY, MM) + ) + + if DDD is not None: + if calendar.isleap(YYYY) is False and DDD == 366: + raise DayOutOfBoundsError( + "{0} is only valid for leap year.".format(DDD) + ) + + return (YYYY, MM, DD, Www, D, DDD) + + @classmethod + def range_check_time(cls, hh=None, mm=None, ss=None, tz=None, rangedict=None): + # Used for midnight and leap second handling + midnight = False # Handle hh = '24' specially + + if rangedict is None: + rangedict = cls.TIME_RANGE_DICT + + if "hh" in rangedict: + try: + hh = rangedict["hh"].rangefunc(hh, rangedict["hh"]) + except HoursOutOfBoundsError as e: + if float(hh) > 24 and float(hh) < 25: + raise MidnightBoundsError("Hour 24 may only represent midnight.") + + raise e + + if "mm" in rangedict: + mm = rangedict["mm"].rangefunc(mm, rangedict["mm"]) + + if "ss" in rangedict: + ss = rangedict["ss"].rangefunc(ss, rangedict["ss"]) + + if hh is not None and hh == 24: + midnight = True + + # Handle midnight range + if midnight is True and ( + (mm is not None and mm != 0) or (ss is not None and ss != 0) + ): + raise MidnightBoundsError("Hour 24 may only represent midnight.") + + if cls.LEAP_SECONDS_SUPPORTED is True: + if hh != 23 and mm != 59 and ss == 60: + raise cls.TIME_SS_LIMIT.rangeexception( + cls.TIME_SS_LIMIT.rangeerrorstring + ) + else: + if hh == 23 and mm == 59 and ss == 60: + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + raise LeapSecondError("Leap seconds are not supported.") + + if ss == 60: + raise cls.TIME_SS_LIMIT.rangeexception( + cls.TIME_SS_LIMIT.rangeerrorstring + ) + + return (hh, mm, ss, tz) + + @classmethod + def range_check_duration( + cls, + PnY=None, + PnM=None, + PnW=None, + PnD=None, + TnH=None, + TnM=None, + TnS=None, + rangedict=None, + ): + if rangedict is None: + rangedict = cls.DURATION_RANGE_DICT + + if "PnY" in rangedict: + PnY = rangedict["PnY"].rangefunc(PnY, rangedict["PnY"]) + + if "PnM" in rangedict: + PnM = rangedict["PnM"].rangefunc(PnM, rangedict["PnM"]) + + if "PnW" in rangedict: + PnW = rangedict["PnW"].rangefunc(PnW, rangedict["PnW"]) + + if "PnD" in rangedict: + PnD = rangedict["PnD"].rangefunc(PnD, rangedict["PnD"]) + + if "TnH" in rangedict: + TnH = rangedict["TnH"].rangefunc(TnH, rangedict["TnH"]) + + if "TnM" in rangedict: + TnM = rangedict["TnM"].rangefunc(TnM, rangedict["TnM"]) + + if "TnS" in rangedict: + TnS = rangedict["TnS"].rangefunc(TnS, rangedict["TnS"]) + + return (PnY, PnM, PnW, PnD, TnH, TnM, TnS) + + @classmethod + def range_check_repeating_interval( + cls, R=None, Rnn=None, interval=None, rangedict=None + ): + if rangedict is None: + rangedict = cls.REPEATING_INTERVAL_RANGE_DICT + + if "Rnn" in rangedict: + Rnn = rangedict["Rnn"].rangefunc(Rnn, rangedict["Rnn"]) + + return (R, Rnn, interval) + + @classmethod + def range_check_timezone( + cls, negative=None, Z=None, hh=None, mm=None, name="", rangedict=None + ): + if rangedict is None: + rangedict = cls.TIMEZONE_RANGE_DICT + + if "hh" in rangedict: + hh = rangedict["hh"].rangefunc(hh, rangedict["hh"]) + + if "mm" in rangedict: + mm = rangedict["mm"].rangefunc(mm, rangedict["mm"]) + + return (negative, Z, hh, mm, name) + + @classmethod + def _build_object(cls, parsetuple): + # Given a TupleBuilder tuple, build the correct object + if type(parsetuple) is DateTuple: + return cls.build_date( + YYYY=parsetuple.YYYY, + MM=parsetuple.MM, + DD=parsetuple.DD, + Www=parsetuple.Www, + D=parsetuple.D, + DDD=parsetuple.DDD, + ) + + if type(parsetuple) is TimeTuple: + return cls.build_time( + hh=parsetuple.hh, mm=parsetuple.mm, ss=parsetuple.ss, tz=parsetuple.tz + ) + + if type(parsetuple) is DatetimeTuple: + return cls.build_datetime(parsetuple.date, parsetuple.time) + + if type(parsetuple) is DurationTuple: + return cls.build_duration( + PnY=parsetuple.PnY, + PnM=parsetuple.PnM, + PnW=parsetuple.PnW, + PnD=parsetuple.PnD, + TnH=parsetuple.TnH, + TnM=parsetuple.TnM, + TnS=parsetuple.TnS, + ) + + if type(parsetuple) is IntervalTuple: + return cls.build_interval( + start=parsetuple.start, end=parsetuple.end, duration=parsetuple.duration + ) + + if type(parsetuple) is RepeatingIntervalTuple: + return cls.build_repeating_interval( + R=parsetuple.R, Rnn=parsetuple.Rnn, interval=parsetuple.interval + ) + + return cls.build_timezone( + negative=parsetuple.negative, + Z=parsetuple.Z, + hh=parsetuple.hh, + mm=parsetuple.mm, + name=parsetuple.name, + ) + + @classmethod + def _is_interval_end_concise(cls, endtuple): + if type(endtuple) is TimeTuple: + return True + + if type(endtuple) is DatetimeTuple: + enddatetuple = endtuple.date + else: + enddatetuple = endtuple + + if enddatetuple.YYYY is None: + return True + + return False + + @classmethod + def _combine_concise_interval_tuples(cls, starttuple, conciseendtuple): + starttimetuple = None + startdatetuple = None + + endtimetuple = None + enddatetuple = None + + if type(starttuple) is DateTuple: + startdatetuple = starttuple + else: + # Start is a datetime + starttimetuple = starttuple.time + startdatetuple = starttuple.date + + if type(conciseendtuple) is DateTuple: + enddatetuple = conciseendtuple + elif type(conciseendtuple) is DatetimeTuple: + enddatetuple = conciseendtuple.date + endtimetuple = conciseendtuple.time + else: + # Time + endtimetuple = conciseendtuple + + if enddatetuple is not None: + if enddatetuple.YYYY is None and enddatetuple.MM is None: + newenddatetuple = DateTuple( + YYYY=startdatetuple.YYYY, + MM=startdatetuple.MM, + DD=enddatetuple.DD, + Www=enddatetuple.Www, + D=enddatetuple.D, + DDD=enddatetuple.DDD, + ) + else: + newenddatetuple = DateTuple( + YYYY=startdatetuple.YYYY, + MM=enddatetuple.MM, + DD=enddatetuple.DD, + Www=enddatetuple.Www, + D=enddatetuple.D, + DDD=enddatetuple.DDD, + ) + + if (starttimetuple is not None and starttimetuple.tz is not None) and ( + endtimetuple is not None and endtimetuple.tz != starttimetuple.tz + ): + # Copy the timezone across + endtimetuple = TimeTuple( + hh=endtimetuple.hh, + mm=endtimetuple.mm, + ss=endtimetuple.ss, + tz=starttimetuple.tz, + ) + + if enddatetuple is not None and endtimetuple is None: + return newenddatetuple + + if enddatetuple is not None and endtimetuple is not None: + return TupleBuilder.build_datetime(newenddatetuple, endtimetuple) + + return TupleBuilder.build_datetime(startdatetuple, endtimetuple) + + +class TupleBuilder(BaseTimeBuilder): + # Builder used to return the arguments as a tuple, cleans up some parse methods + @classmethod + def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None): + + return DateTuple(YYYY, MM, DD, Www, D, DDD) + + @classmethod + def build_time(cls, hh=None, mm=None, ss=None, tz=None): + return TimeTuple(hh, mm, ss, tz) + + @classmethod + def build_datetime(cls, date, time): + return DatetimeTuple(date, time) + + @classmethod + def build_duration( + cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None + ): + + return DurationTuple(PnY, PnM, PnW, PnD, TnH, TnM, TnS) + + @classmethod + def build_interval(cls, start=None, end=None, duration=None): + return IntervalTuple(start, end, duration) + + @classmethod + def build_repeating_interval(cls, R=None, Rnn=None, interval=None): + return RepeatingIntervalTuple(R, Rnn, interval) + + @classmethod + def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""): + return TimezoneTuple(negative, Z, hh, mm, name) diff --git a/libs/aniso8601/builders/python.py b/libs/aniso8601/builders/python.py new file mode 100644 index 000000000..8956740e7 --- /dev/null +++ b/libs/aniso8601/builders/python.py @@ -0,0 +1,705 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import datetime +from collections import namedtuple +from functools import partial + +from aniso8601.builders import ( + BaseTimeBuilder, + DatetimeTuple, + DateTuple, + Limit, + TimeTuple, + TupleBuilder, + cast, + range_check, +) +from aniso8601.exceptions import ( + DayOutOfBoundsError, + HoursOutOfBoundsError, + ISOFormatError, + LeapSecondError, + MidnightBoundsError, + MinutesOutOfBoundsError, + MonthOutOfBoundsError, + SecondsOutOfBoundsError, + WeekOutOfBoundsError, + YearOutOfBoundsError, +) +from aniso8601.utcoffset import UTCOffset + +DAYS_PER_YEAR = 365 +DAYS_PER_MONTH = 30 +DAYS_PER_WEEK = 7 + +HOURS_PER_DAY = 24 + +MINUTES_PER_HOUR = 60 +MINUTES_PER_DAY = MINUTES_PER_HOUR * HOURS_PER_DAY + +SECONDS_PER_MINUTE = 60 +SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE + +MICROSECONDS_PER_SECOND = int(1e6) + +MICROSECONDS_PER_MINUTE = 60 * MICROSECONDS_PER_SECOND +MICROSECONDS_PER_HOUR = 60 * MICROSECONDS_PER_MINUTE +MICROSECONDS_PER_DAY = 24 * MICROSECONDS_PER_HOUR +MICROSECONDS_PER_WEEK = 7 * MICROSECONDS_PER_DAY +MICROSECONDS_PER_MONTH = DAYS_PER_MONTH * MICROSECONDS_PER_DAY +MICROSECONDS_PER_YEAR = DAYS_PER_YEAR * MICROSECONDS_PER_DAY + +TIMEDELTA_MAX_DAYS = datetime.timedelta.max.days + +FractionalComponent = namedtuple( + "FractionalComponent", ["principal", "microsecondremainder"] +) + + +def year_range_check(valuestr, limit): + YYYYstr = valuestr + + # Truncated dates, like '19', refer to 1900-1999 inclusive, + # we simply parse to 1900 + if len(valuestr) < 4: + # Shift 0s in from the left to form complete year + YYYYstr = valuestr.ljust(4, "0") + + return range_check(YYYYstr, limit) + + +def fractional_range_check(conversion, valuestr, limit): + if valuestr is None: + return None + + if "." in valuestr: + castfunc = partial(_cast_to_fractional_component, conversion) + else: + castfunc = int + + value = cast(valuestr, castfunc, thrownmessage=limit.casterrorstring) + + if type(value) is FractionalComponent: + tocheck = float(valuestr) + else: + tocheck = int(valuestr) + + if limit.min is not None and tocheck < limit.min: + raise limit.rangeexception(limit.rangeerrorstring) + + if limit.max is not None and tocheck > limit.max: + raise limit.rangeexception(limit.rangeerrorstring) + + return value + + +def _cast_to_fractional_component(conversion, floatstr): + # Splits a string with a decimal point into an int, and + # int representing the floating point remainder as a number + # of microseconds, determined by multiplying by conversion + intpart, floatpart = floatstr.split(".") + + intvalue = int(intpart) + preconvertedvalue = int(floatpart) + + convertedvalue = (preconvertedvalue * conversion) // (10 ** len(floatpart)) + + return FractionalComponent(intvalue, convertedvalue) + + +class PythonTimeBuilder(BaseTimeBuilder): + # 0000 (1 BC) is not representable as a Python date + DATE_YYYY_LIMIT = Limit( + "Invalid year string.", + datetime.MINYEAR, + datetime.MAXYEAR, + YearOutOfBoundsError, + "Year must be between {0}..{1}.".format(datetime.MINYEAR, datetime.MAXYEAR), + year_range_check, + ) + TIME_HH_LIMIT = Limit( + "Invalid hour string.", + 0, + 24, + HoursOutOfBoundsError, + "Hour must be between 0..24 with " "24 representing midnight.", + partial(fractional_range_check, MICROSECONDS_PER_HOUR), + ) + TIME_MM_LIMIT = Limit( + "Invalid minute string.", + 0, + 59, + MinutesOutOfBoundsError, + "Minute must be between 0..59.", + partial(fractional_range_check, MICROSECONDS_PER_MINUTE), + ) + TIME_SS_LIMIT = Limit( + "Invalid second string.", + 0, + 60, + SecondsOutOfBoundsError, + "Second must be between 0..60 with " "60 representing a leap second.", + partial(fractional_range_check, MICROSECONDS_PER_SECOND), + ) + DURATION_PNY_LIMIT = Limit( + "Invalid year duration string.", + None, + None, + YearOutOfBoundsError, + None, + partial(fractional_range_check, MICROSECONDS_PER_YEAR), + ) + DURATION_PNM_LIMIT = Limit( + "Invalid month duration string.", + None, + None, + MonthOutOfBoundsError, + None, + partial(fractional_range_check, MICROSECONDS_PER_MONTH), + ) + DURATION_PNW_LIMIT = Limit( + "Invalid week duration string.", + None, + None, + WeekOutOfBoundsError, + None, + partial(fractional_range_check, MICROSECONDS_PER_WEEK), + ) + DURATION_PND_LIMIT = Limit( + "Invalid day duration string.", + None, + None, + DayOutOfBoundsError, + None, + partial(fractional_range_check, MICROSECONDS_PER_DAY), + ) + DURATION_TNH_LIMIT = Limit( + "Invalid hour duration string.", + None, + None, + HoursOutOfBoundsError, + None, + partial(fractional_range_check, MICROSECONDS_PER_HOUR), + ) + DURATION_TNM_LIMIT = Limit( + "Invalid minute duration string.", + None, + None, + MinutesOutOfBoundsError, + None, + partial(fractional_range_check, MICROSECONDS_PER_MINUTE), + ) + DURATION_TNS_LIMIT = Limit( + "Invalid second duration string.", + None, + None, + SecondsOutOfBoundsError, + None, + partial(fractional_range_check, MICROSECONDS_PER_SECOND), + ) + + DATE_RANGE_DICT = BaseTimeBuilder.DATE_RANGE_DICT + DATE_RANGE_DICT["YYYY"] = DATE_YYYY_LIMIT + + TIME_RANGE_DICT = {"hh": TIME_HH_LIMIT, "mm": TIME_MM_LIMIT, "ss": TIME_SS_LIMIT} + + DURATION_RANGE_DICT = { + "PnY": DURATION_PNY_LIMIT, + "PnM": DURATION_PNM_LIMIT, + "PnW": DURATION_PNW_LIMIT, + "PnD": DURATION_PND_LIMIT, + "TnH": DURATION_TNH_LIMIT, + "TnM": DURATION_TNM_LIMIT, + "TnS": DURATION_TNS_LIMIT, + } + + @classmethod + def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None): + YYYY, MM, DD, Www, D, DDD = cls.range_check_date(YYYY, MM, DD, Www, D, DDD) + + if MM is None: + MM = 1 + + if DD is None: + DD = 1 + + if DDD is not None: + return PythonTimeBuilder._build_ordinal_date(YYYY, DDD) + + if Www is not None: + return PythonTimeBuilder._build_week_date(YYYY, Www, isoday=D) + + return datetime.date(YYYY, MM, DD) + + @classmethod + def build_time(cls, hh=None, mm=None, ss=None, tz=None): + # Builds a time from the given parts, handling fractional arguments + # where necessary + hours = 0 + minutes = 0 + seconds = 0 + microseconds = 0 + + hh, mm, ss, tz = cls.range_check_time(hh, mm, ss, tz) + + if type(hh) is FractionalComponent: + hours = hh.principal + microseconds = hh.microsecondremainder + elif hh is not None: + hours = hh + + if type(mm) is FractionalComponent: + minutes = mm.principal + microseconds = mm.microsecondremainder + elif mm is not None: + minutes = mm + + if type(ss) is FractionalComponent: + seconds = ss.principal + microseconds = ss.microsecondremainder + elif ss is not None: + seconds = ss + + ( + hours, + minutes, + seconds, + microseconds, + ) = PythonTimeBuilder._distribute_microseconds( + microseconds, + (hours, minutes, seconds), + (MICROSECONDS_PER_HOUR, MICROSECONDS_PER_MINUTE, MICROSECONDS_PER_SECOND), + ) + + # Move midnight into range + if hours == 24: + hours = 0 + + # Datetimes don't handle fractional components, so we use a timedelta + if tz is not None: + return ( + datetime.datetime( + 1, 1, 1, hour=hours, minute=minutes, tzinfo=cls._build_object(tz) + ) + + datetime.timedelta(seconds=seconds, microseconds=microseconds) + ).timetz() + + return ( + datetime.datetime(1, 1, 1, hour=hours, minute=minutes) + + datetime.timedelta(seconds=seconds, microseconds=microseconds) + ).time() + + @classmethod + def build_datetime(cls, date, time): + return datetime.datetime.combine( + cls._build_object(date), cls._build_object(time) + ) + + @classmethod + def build_duration( + cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None + ): + # PnY and PnM will be distributed to PnD, microsecond remainder to TnS + PnY, PnM, PnW, PnD, TnH, TnM, TnS = cls.range_check_duration( + PnY, PnM, PnW, PnD, TnH, TnM, TnS + ) + + seconds = TnS.principal + microseconds = TnS.microsecondremainder + + return datetime.timedelta( + days=PnD, + seconds=seconds, + microseconds=microseconds, + minutes=TnM, + hours=TnH, + weeks=PnW, + ) + + @classmethod + def build_interval(cls, start=None, end=None, duration=None): + start, end, duration = cls.range_check_interval(start, end, duration) + + if start is not None and end is not None: + # <start>/<end> + startobject = cls._build_object(start) + endobject = cls._build_object(end) + + return (startobject, endobject) + + durationobject = cls._build_object(duration) + + # Determine if datetime promotion is required + datetimerequired = ( + duration.TnH is not None + or duration.TnM is not None + or duration.TnS is not None + or durationobject.seconds != 0 + or durationobject.microseconds != 0 + ) + + if end is not None: + # <duration>/<end> + endobject = cls._build_object(end) + + # Range check + if type(end) is DateTuple and datetimerequired is True: + # <end> is a date, and <duration> requires datetime resolution + return ( + endobject, + cls.build_datetime(end, TupleBuilder.build_time()) - durationobject, + ) + + return (endobject, endobject - durationobject) + + # <start>/<duration> + startobject = cls._build_object(start) + + # Range check + if type(start) is DateTuple and datetimerequired is True: + # <start> is a date, and <duration> requires datetime resolution + return ( + startobject, + cls.build_datetime(start, TupleBuilder.build_time()) + durationobject, + ) + + return (startobject, startobject + durationobject) + + @classmethod + def build_repeating_interval(cls, R=None, Rnn=None, interval=None): + startobject = None + endobject = None + + R, Rnn, interval = cls.range_check_repeating_interval(R, Rnn, interval) + + if interval.start is not None: + startobject = cls._build_object(interval.start) + + if interval.end is not None: + endobject = cls._build_object(interval.end) + + if interval.duration is not None: + durationobject = cls._build_object(interval.duration) + else: + durationobject = endobject - startobject + + if R is True: + if startobject is not None: + return cls._date_generator_unbounded(startobject, durationobject) + + return cls._date_generator_unbounded(endobject, -durationobject) + + iterations = int(Rnn) + + if startobject is not None: + return cls._date_generator(startobject, durationobject, iterations) + + return cls._date_generator(endobject, -durationobject, iterations) + + @classmethod + def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""): + negative, Z, hh, mm, name = cls.range_check_timezone(negative, Z, hh, mm, name) + + if Z is True: + # Z -> UTC + return UTCOffset(name="UTC", minutes=0) + + tzhour = int(hh) + + if mm is not None: + tzminute = int(mm) + else: + tzminute = 0 + + if negative is True: + return UTCOffset(name=name, minutes=-(tzhour * 60 + tzminute)) + + return UTCOffset(name=name, minutes=tzhour * 60 + tzminute) + + @classmethod + def range_check_duration( + cls, + PnY=None, + PnM=None, + PnW=None, + PnD=None, + TnH=None, + TnM=None, + TnS=None, + rangedict=None, + ): + years = 0 + months = 0 + days = 0 + weeks = 0 + hours = 0 + minutes = 0 + seconds = 0 + microseconds = 0 + + PnY, PnM, PnW, PnD, TnH, TnM, TnS = BaseTimeBuilder.range_check_duration( + PnY, PnM, PnW, PnD, TnH, TnM, TnS, rangedict=cls.DURATION_RANGE_DICT + ) + + if PnY is not None: + if type(PnY) is FractionalComponent: + years = PnY.principal + microseconds = PnY.microsecondremainder + else: + years = PnY + + if years * DAYS_PER_YEAR > TIMEDELTA_MAX_DAYS: + raise YearOutOfBoundsError("Duration exceeds maximum timedelta size.") + + if PnM is not None: + if type(PnM) is FractionalComponent: + months = PnM.principal + microseconds = PnM.microsecondremainder + else: + months = PnM + + if months * DAYS_PER_MONTH > TIMEDELTA_MAX_DAYS: + raise MonthOutOfBoundsError("Duration exceeds maximum timedelta size.") + + if PnW is not None: + if type(PnW) is FractionalComponent: + weeks = PnW.principal + microseconds = PnW.microsecondremainder + else: + weeks = PnW + + if weeks * DAYS_PER_WEEK > TIMEDELTA_MAX_DAYS: + raise WeekOutOfBoundsError("Duration exceeds maximum timedelta size.") + + if PnD is not None: + if type(PnD) is FractionalComponent: + days = PnD.principal + microseconds = PnD.microsecondremainder + else: + days = PnD + + if days > TIMEDELTA_MAX_DAYS: + raise DayOutOfBoundsError("Duration exceeds maximum timedelta size.") + + if TnH is not None: + if type(TnH) is FractionalComponent: + hours = TnH.principal + microseconds = TnH.microsecondremainder + else: + hours = TnH + + if hours // HOURS_PER_DAY > TIMEDELTA_MAX_DAYS: + raise HoursOutOfBoundsError("Duration exceeds maximum timedelta size.") + + if TnM is not None: + if type(TnM) is FractionalComponent: + minutes = TnM.principal + microseconds = TnM.microsecondremainder + else: + minutes = TnM + + if minutes // MINUTES_PER_DAY > TIMEDELTA_MAX_DAYS: + raise MinutesOutOfBoundsError( + "Duration exceeds maximum timedelta size." + ) + + if TnS is not None: + if type(TnS) is FractionalComponent: + seconds = TnS.principal + microseconds = TnS.microsecondremainder + else: + seconds = TnS + + if seconds // SECONDS_PER_DAY > TIMEDELTA_MAX_DAYS: + raise SecondsOutOfBoundsError( + "Duration exceeds maximum timedelta size." + ) + + ( + years, + months, + weeks, + days, + hours, + minutes, + seconds, + microseconds, + ) = PythonTimeBuilder._distribute_microseconds( + microseconds, + (years, months, weeks, days, hours, minutes, seconds), + ( + MICROSECONDS_PER_YEAR, + MICROSECONDS_PER_MONTH, + MICROSECONDS_PER_WEEK, + MICROSECONDS_PER_DAY, + MICROSECONDS_PER_HOUR, + MICROSECONDS_PER_MINUTE, + MICROSECONDS_PER_SECOND, + ), + ) + + # Note that weeks can be handled without conversion to days + totaldays = years * DAYS_PER_YEAR + months * DAYS_PER_MONTH + days + + # Check against timedelta limits + if ( + totaldays + + weeks * DAYS_PER_WEEK + + hours // HOURS_PER_DAY + + minutes // MINUTES_PER_DAY + + seconds // SECONDS_PER_DAY + > TIMEDELTA_MAX_DAYS + ): + raise DayOutOfBoundsError("Duration exceeds maximum timedelta size.") + + return ( + None, + None, + weeks, + totaldays, + hours, + minutes, + FractionalComponent(seconds, microseconds), + ) + + @classmethod + def range_check_interval(cls, start=None, end=None, duration=None): + # Handles concise format, range checks any potential durations + if start is not None and end is not None: + # <start>/<end> + # Handle concise format + if cls._is_interval_end_concise(end) is True: + end = cls._combine_concise_interval_tuples(start, end) + + return (start, end, duration) + + durationobject = cls._build_object(duration) + + if end is not None: + # <duration>/<end> + endobject = cls._build_object(end) + + # Range check + if type(end) is DateTuple: + enddatetime = cls.build_datetime(end, TupleBuilder.build_time()) + + if enddatetime - datetime.datetime.min < durationobject: + raise YearOutOfBoundsError("Interval end less than minimium date.") + else: + mindatetime = datetime.datetime.min + + if end.time.tz is not None: + mindatetime = mindatetime.replace(tzinfo=endobject.tzinfo) + + if endobject - mindatetime < durationobject: + raise YearOutOfBoundsError("Interval end less than minimium date.") + else: + # <start>/<duration> + startobject = cls._build_object(start) + + # Range check + if type(start) is DateTuple: + startdatetime = cls.build_datetime(start, TupleBuilder.build_time()) + + if datetime.datetime.max - startdatetime < durationobject: + raise YearOutOfBoundsError( + "Interval end greater than maximum date." + ) + else: + maxdatetime = datetime.datetime.max + + if start.time.tz is not None: + maxdatetime = maxdatetime.replace(tzinfo=startobject.tzinfo) + + if maxdatetime - startobject < durationobject: + raise YearOutOfBoundsError( + "Interval end greater than maximum date." + ) + + return (start, end, duration) + + @staticmethod + def _build_week_date(isoyear, isoweek, isoday=None): + if isoday is None: + return PythonTimeBuilder._iso_year_start(isoyear) + datetime.timedelta( + weeks=isoweek - 1 + ) + + return PythonTimeBuilder._iso_year_start(isoyear) + datetime.timedelta( + weeks=isoweek - 1, days=isoday - 1 + ) + + @staticmethod + def _build_ordinal_date(isoyear, isoday): + # Day of year to a date + # https://stackoverflow.com/questions/2427555/python-question-year-and-day-of-year-to-date + builtdate = datetime.date(isoyear, 1, 1) + datetime.timedelta(days=isoday - 1) + + return builtdate + + @staticmethod + def _iso_year_start(isoyear): + # Given an ISO year, returns the equivalent of the start of the year + # on the Gregorian calendar (which is used by Python) + # Stolen from: + # http://stackoverflow.com/questions/304256/whats-the-best-way-to-find-the-inverse-of-datetime-isocalendar + + # Determine the location of the 4th of January, the first week of + # the ISO year is the week containing the 4th of January + # http://en.wikipedia.org/wiki/ISO_week_date + fourth_jan = datetime.date(isoyear, 1, 4) + + # Note the conversion from ISO day (1 - 7) and Python day (0 - 6) + delta = datetime.timedelta(days=fourth_jan.isoweekday() - 1) + + # Return the start of the year + return fourth_jan - delta + + @staticmethod + def _date_generator(startdate, timedelta, iterations): + currentdate = startdate + currentiteration = 0 + + while currentiteration < iterations: + yield currentdate + + # Update the values + currentdate += timedelta + currentiteration += 1 + + @staticmethod + def _date_generator_unbounded(startdate, timedelta): + currentdate = startdate + + while True: + yield currentdate + + # Update the value + currentdate += timedelta + + @staticmethod + def _distribute_microseconds(todistribute, recipients, reductions): + # Given a number of microseconds as int, a tuple of ints length n + # to distribute to, and a tuple of ints length n to divide todistribute + # by (from largest to smallest), returns a tuple of length n + 1, with + # todistribute divided across recipients using the reductions, with + # the final remainder returned as the final tuple member + results = [] + + remainder = todistribute + + for index, reduction in enumerate(reductions): + additional, remainder = divmod(remainder, reduction) + + results.append(recipients[index] + additional) + + # Always return the remaining microseconds + results.append(remainder) + + return tuple(results) diff --git a/libs/aniso8601/builders/tests/__init__.py b/libs/aniso8601/builders/tests/__init__.py new file mode 100644 index 000000000..1a94e017a --- /dev/null +++ b/libs/aniso8601/builders/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. diff --git a/libs/aniso8601/builders/tests/test_init.py b/libs/aniso8601/builders/tests/test_init.py new file mode 100644 index 000000000..7c9f092c7 --- /dev/null +++ b/libs/aniso8601/builders/tests/test_init.py @@ -0,0 +1,838 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +import aniso8601 +from aniso8601.builders import ( + BaseTimeBuilder, + DatetimeTuple, + DateTuple, + DurationTuple, + IntervalTuple, + RepeatingIntervalTuple, + TimeTuple, + TimezoneTuple, + TupleBuilder, + cast, +) +from aniso8601.exceptions import ( + DayOutOfBoundsError, + HoursOutOfBoundsError, + ISOFormatError, + LeapSecondError, + MidnightBoundsError, + MinutesOutOfBoundsError, + MonthOutOfBoundsError, + SecondsOutOfBoundsError, + WeekOutOfBoundsError, +) +from aniso8601.tests.compat import mock + + +class LeapSecondSupportingTestBuilder(BaseTimeBuilder): + LEAP_SECONDS_SUPPORTED = True + + +class TestBuilderFunctions(unittest.TestCase): + def test_cast(self): + self.assertEqual(cast("1", int), 1) + self.assertEqual(cast("-2", int), -2) + self.assertEqual(cast("3", float), float(3)) + self.assertEqual(cast("-4", float), float(-4)) + self.assertEqual(cast("5.6", float), 5.6) + self.assertEqual(cast("-7.8", float), -7.8) + + def test_cast_exception(self): + with self.assertRaises(ISOFormatError): + cast("asdf", int) + + with self.assertRaises(ISOFormatError): + cast("asdf", float) + + def test_cast_caughtexception(self): + def tester(value): + raise RuntimeError + + with self.assertRaises(ISOFormatError): + cast("asdf", tester, caughtexceptions=(RuntimeError,)) + + def test_cast_thrownexception(self): + with self.assertRaises(RuntimeError): + cast("asdf", int, thrownexception=RuntimeError) + + +class TestBaseTimeBuilder(unittest.TestCase): + def test_build_date(self): + with self.assertRaises(NotImplementedError): + BaseTimeBuilder.build_date() + + def test_build_time(self): + with self.assertRaises(NotImplementedError): + BaseTimeBuilder.build_time() + + def test_build_datetime(self): + with self.assertRaises(NotImplementedError): + BaseTimeBuilder.build_datetime(None, None) + + def test_build_duration(self): + with self.assertRaises(NotImplementedError): + BaseTimeBuilder.build_duration() + + def test_build_interval(self): + with self.assertRaises(NotImplementedError): + BaseTimeBuilder.build_interval() + + def test_build_repeating_interval(self): + with self.assertRaises(NotImplementedError): + BaseTimeBuilder.build_repeating_interval() + + def test_build_timezone(self): + with self.assertRaises(NotImplementedError): + BaseTimeBuilder.build_timezone() + + def test_range_check_date(self): + # Check the calendar for day ranges + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="0007", MM="02", DD="30") + + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="0007", DDD="366") + + with self.assertRaises(MonthOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="4333", MM="30", DD="30") + + # 0 isn't a valid week number + with self.assertRaises(WeekOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="2003", Www="00") + + # Week must not be larger than 53 + with self.assertRaises(WeekOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="2004", Www="54") + + # 0 isn't a valid day number + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="2001", Www="02", D="0") + + # Day must not be larger than 7 + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="2001", Www="02", D="8") + + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="1981", DDD="000") + + # Day must be 365, or 366, not larger + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="1234", DDD="000") + + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="1234", DDD="367") + + # https://bitbucket.org/nielsenb/aniso8601/issues/14/parsing-ordinal-dates-should-only-allow + with self.assertRaises(DayOutOfBoundsError): + BaseTimeBuilder.range_check_date(YYYY="1981", DDD="366") + + # Make sure Nones pass through unmodified + self.assertEqual( + BaseTimeBuilder.range_check_date(rangedict={}), + (None, None, None, None, None, None), + ) + + def test_range_check_time(self): + # Leap seconds not supported + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + # https://bitbucket.org/nielsenb/aniso8601/issues/13/parsing-of-leap-second-gives-wildly + with self.assertRaises(LeapSecondError): + BaseTimeBuilder.range_check_time(hh="23", mm="59", ss="60") + + with self.assertRaises(SecondsOutOfBoundsError): + BaseTimeBuilder.range_check_time(hh="00", mm="00", ss="60") + + with self.assertRaises(SecondsOutOfBoundsError): + BaseTimeBuilder.range_check_time(hh="00", mm="00", ss="61") + + with self.assertRaises(MinutesOutOfBoundsError): + BaseTimeBuilder.range_check_time(hh="00", mm="61") + + with self.assertRaises(MinutesOutOfBoundsError): + BaseTimeBuilder.range_check_time(hh="00", mm="60") + + with self.assertRaises(MinutesOutOfBoundsError): + BaseTimeBuilder.range_check_time(hh="00", mm="60.1") + + with self.assertRaises(HoursOutOfBoundsError): + BaseTimeBuilder.range_check_time(hh="25") + + # Hour 24 can only represent midnight + with self.assertRaises(MidnightBoundsError): + BaseTimeBuilder.range_check_time(hh="24", mm="00", ss="01") + + with self.assertRaises(MidnightBoundsError): + BaseTimeBuilder.range_check_time(hh="24", mm="00.1") + + with self.assertRaises(MidnightBoundsError): + BaseTimeBuilder.range_check_time(hh="24", mm="01") + + with self.assertRaises(MidnightBoundsError): + BaseTimeBuilder.range_check_time(hh="24.1") + + # Leap seconds not supported + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + # https://bitbucket.org/nielsenb/aniso8601/issues/13/parsing-of-leap-second-gives-wildly + with self.assertRaises(LeapSecondError): + BaseTimeBuilder.range_check_time(hh="23", mm="59", ss="60") + + # Make sure Nones pass through unmodified + self.assertEqual( + BaseTimeBuilder.range_check_time(rangedict={}), (None, None, None, None) + ) + + def test_range_check_time_leap_seconds_supported(self): + self.assertEqual( + LeapSecondSupportingTestBuilder.range_check_time(hh="23", mm="59", ss="60"), + (23, 59, 60, None), + ) + + with self.assertRaises(SecondsOutOfBoundsError): + LeapSecondSupportingTestBuilder.range_check_time(hh="01", mm="02", ss="60") + + def test_range_check_duration(self): + self.assertEqual( + BaseTimeBuilder.range_check_duration(), + (None, None, None, None, None, None, None), + ) + + self.assertEqual( + BaseTimeBuilder.range_check_duration(rangedict={}), + (None, None, None, None, None, None, None), + ) + + def test_range_check_repeating_interval(self): + self.assertEqual( + BaseTimeBuilder.range_check_repeating_interval(), (None, None, None) + ) + + self.assertEqual( + BaseTimeBuilder.range_check_repeating_interval(rangedict={}), + (None, None, None), + ) + + def test_range_check_timezone(self): + self.assertEqual( + BaseTimeBuilder.range_check_timezone(), (None, None, None, None, "") + ) + + self.assertEqual( + BaseTimeBuilder.range_check_timezone(rangedict={}), + (None, None, None, None, ""), + ) + + def test_build_object(self): + datetest = ( + DateTuple("1", "2", "3", "4", "5", "6"), + {"YYYY": "1", "MM": "2", "DD": "3", "Www": "4", "D": "5", "DDD": "6"}, + ) + + timetest = ( + TimeTuple("1", "2", "3", TimezoneTuple(False, False, "4", "5", "tz name")), + { + "hh": "1", + "mm": "2", + "ss": "3", + "tz": TimezoneTuple(False, False, "4", "5", "tz name"), + }, + ) + + datetimetest = ( + DatetimeTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + TimeTuple( + "7", "8", "9", TimezoneTuple(True, False, "10", "11", "tz name") + ), + ), + ( + DateTuple("1", "2", "3", "4", "5", "6"), + TimeTuple( + "7", "8", "9", TimezoneTuple(True, False, "10", "11", "tz name") + ), + ), + ) + + durationtest = ( + DurationTuple("1", "2", "3", "4", "5", "6", "7"), + { + "PnY": "1", + "PnM": "2", + "PnW": "3", + "PnD": "4", + "TnH": "5", + "TnM": "6", + "TnS": "7", + }, + ) + + intervaltests = ( + ( + IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + DateTuple("7", "8", "9", "10", "11", "12"), + None, + ), + { + "start": DateTuple("1", "2", "3", "4", "5", "6"), + "end": DateTuple("7", "8", "9", "10", "11", "12"), + "duration": None, + }, + ), + ( + IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + None, + DurationTuple("7", "8", "9", "10", "11", "12", "13"), + ), + { + "start": DateTuple("1", "2", "3", "4", "5", "6"), + "end": None, + "duration": DurationTuple("7", "8", "9", "10", "11", "12", "13"), + }, + ), + ( + IntervalTuple( + None, + TimeTuple( + "1", "2", "3", TimezoneTuple(True, False, "4", "5", "tz name") + ), + DurationTuple("6", "7", "8", "9", "10", "11", "12"), + ), + { + "start": None, + "end": TimeTuple( + "1", "2", "3", TimezoneTuple(True, False, "4", "5", "tz name") + ), + "duration": DurationTuple("6", "7", "8", "9", "10", "11", "12"), + }, + ), + ) + + repeatingintervaltests = ( + ( + RepeatingIntervalTuple( + True, + None, + IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + DateTuple("7", "8", "9", "10", "11", "12"), + None, + ), + ), + { + "R": True, + "Rnn": None, + "interval": IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + DateTuple("7", "8", "9", "10", "11", "12"), + None, + ), + }, + ), + ( + RepeatingIntervalTuple( + False, + "1", + IntervalTuple( + DatetimeTuple( + DateTuple("2", "3", "4", "5", "6", "7"), + TimeTuple("8", "9", "10", None), + ), + DatetimeTuple( + DateTuple("11", "12", "13", "14", "15", "16"), + TimeTuple("17", "18", "19", None), + ), + None, + ), + ), + { + "R": False, + "Rnn": "1", + "interval": IntervalTuple( + DatetimeTuple( + DateTuple("2", "3", "4", "5", "6", "7"), + TimeTuple("8", "9", "10", None), + ), + DatetimeTuple( + DateTuple("11", "12", "13", "14", "15", "16"), + TimeTuple("17", "18", "19", None), + ), + None, + ), + }, + ), + ) + + timezonetest = ( + TimezoneTuple(False, False, "1", "2", "+01:02"), + {"negative": False, "Z": False, "hh": "1", "mm": "2", "name": "+01:02"}, + ) + + with mock.patch.object( + aniso8601.builders.BaseTimeBuilder, "build_date" + ) as mock_build: + mock_build.return_value = datetest[0] + + result = BaseTimeBuilder._build_object(datetest[0]) + + self.assertEqual(result, datetest[0]) + mock_build.assert_called_once_with(**datetest[1]) + + with mock.patch.object( + aniso8601.builders.BaseTimeBuilder, "build_time" + ) as mock_build: + mock_build.return_value = timetest[0] + + result = BaseTimeBuilder._build_object(timetest[0]) + + self.assertEqual(result, timetest[0]) + mock_build.assert_called_once_with(**timetest[1]) + + with mock.patch.object( + aniso8601.builders.BaseTimeBuilder, "build_datetime" + ) as mock_build: + mock_build.return_value = datetimetest[0] + + result = BaseTimeBuilder._build_object(datetimetest[0]) + + self.assertEqual(result, datetimetest[0]) + mock_build.assert_called_once_with(*datetimetest[1]) + + with mock.patch.object( + aniso8601.builders.BaseTimeBuilder, "build_duration" + ) as mock_build: + mock_build.return_value = durationtest[0] + + result = BaseTimeBuilder._build_object(durationtest[0]) + + self.assertEqual(result, durationtest[0]) + mock_build.assert_called_once_with(**durationtest[1]) + + for intervaltest in intervaltests: + with mock.patch.object( + aniso8601.builders.BaseTimeBuilder, "build_interval" + ) as mock_build: + mock_build.return_value = intervaltest[0] + + result = BaseTimeBuilder._build_object(intervaltest[0]) + + self.assertEqual(result, intervaltest[0]) + mock_build.assert_called_once_with(**intervaltest[1]) + + for repeatingintervaltest in repeatingintervaltests: + with mock.patch.object( + aniso8601.builders.BaseTimeBuilder, "build_repeating_interval" + ) as mock_build: + mock_build.return_value = repeatingintervaltest[0] + + result = BaseTimeBuilder._build_object(repeatingintervaltest[0]) + + self.assertEqual(result, repeatingintervaltest[0]) + mock_build.assert_called_once_with(**repeatingintervaltest[1]) + + with mock.patch.object( + aniso8601.builders.BaseTimeBuilder, "build_timezone" + ) as mock_build: + mock_build.return_value = timezonetest[0] + + result = BaseTimeBuilder._build_object(timezonetest[0]) + + self.assertEqual(result, timezonetest[0]) + mock_build.assert_called_once_with(**timezonetest[1]) + + def test_is_interval_end_concise(self): + self.assertTrue( + BaseTimeBuilder._is_interval_end_concise(TimeTuple("1", "2", "3", None)) + ) + self.assertTrue( + BaseTimeBuilder._is_interval_end_concise( + DateTuple(None, "2", "3", "4", "5", "6") + ) + ) + self.assertTrue( + BaseTimeBuilder._is_interval_end_concise( + DatetimeTuple( + DateTuple(None, "2", "3", "4", "5", "6"), + TimeTuple("7", "8", "9", None), + ) + ) + ) + + self.assertFalse( + BaseTimeBuilder._is_interval_end_concise( + DateTuple("1", "2", "3", "4", "5", "6") + ) + ) + self.assertFalse( + BaseTimeBuilder._is_interval_end_concise( + DatetimeTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + TimeTuple("7", "8", "9", None), + ) + ) + ) + + def test_combine_concise_interval_tuples(self): + testtuples = ( + ( + DateTuple("2020", "01", "01", None, None, None), + DateTuple(None, None, "02", None, None, None), + DateTuple("2020", "01", "02", None, None, None), + ), + ( + DateTuple("2008", "02", "15", None, None, None), + DateTuple(None, "03", "14", None, None, None), + DateTuple("2008", "03", "14", None, None, None), + ), + ( + DatetimeTuple( + DateTuple("2007", "12", "14", None, None, None), + TimeTuple("13", "30", None, None), + ), + TimeTuple("15", "30", None, None), + DatetimeTuple( + DateTuple("2007", "12", "14", None, None, None), + TimeTuple("15", "30", None, None), + ), + ), + ( + DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("09", "00", None, None), + ), + DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + DatetimeTuple( + DateTuple("2007", "11", "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + ), + ( + DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + DatetimeTuple( + DateTuple(None, None, "16", None, None, None), + TimeTuple("00", "00", None, None), + ), + DatetimeTuple( + DateTuple("2007", "11", "16", None, None, None), + TimeTuple("00", "00", None, None), + ), + ), + ( + DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple( + "09", "00", None, TimezoneTuple(False, True, None, None, "Z") + ), + ), + DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + DatetimeTuple( + DateTuple("2007", "11", "15", None, None, None), + TimeTuple( + "17", "00", None, TimezoneTuple(False, True, None, None, "Z") + ), + ), + ), + ) + + for testtuple in testtuples: + result = BaseTimeBuilder._combine_concise_interval_tuples( + testtuple[0], testtuple[1] + ) + self.assertEqual(result, testtuple[2]) + + +class TestTupleBuilder(unittest.TestCase): + def test_build_date(self): + datetuple = TupleBuilder.build_date() + + self.assertEqual(datetuple, DateTuple(None, None, None, None, None, None)) + + datetuple = TupleBuilder.build_date( + YYYY="1", MM="2", DD="3", Www="4", D="5", DDD="6" + ) + + self.assertEqual(datetuple, DateTuple("1", "2", "3", "4", "5", "6")) + + def test_build_time(self): + testtuples = ( + ({}, TimeTuple(None, None, None, None)), + ( + {"hh": "1", "mm": "2", "ss": "3", "tz": None}, + TimeTuple("1", "2", "3", None), + ), + ( + { + "hh": "1", + "mm": "2", + "ss": "3", + "tz": TimezoneTuple(False, False, "4", "5", "tz name"), + }, + TimeTuple( + "1", "2", "3", TimezoneTuple(False, False, "4", "5", "tz name") + ), + ), + ) + + for testtuple in testtuples: + self.assertEqual(TupleBuilder.build_time(**testtuple[0]), testtuple[1]) + + def test_build_datetime(self): + testtuples = ( + ( + { + "date": DateTuple("1", "2", "3", "4", "5", "6"), + "time": TimeTuple("7", "8", "9", None), + }, + DatetimeTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + TimeTuple("7", "8", "9", None), + ), + ), + ( + { + "date": DateTuple("1", "2", "3", "4", "5", "6"), + "time": TimeTuple( + "7", "8", "9", TimezoneTuple(True, False, "10", "11", "tz name") + ), + }, + DatetimeTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + TimeTuple( + "7", "8", "9", TimezoneTuple(True, False, "10", "11", "tz name") + ), + ), + ), + ) + + for testtuple in testtuples: + self.assertEqual(TupleBuilder.build_datetime(**testtuple[0]), testtuple[1]) + + def test_build_duration(self): + testtuples = ( + ({}, DurationTuple(None, None, None, None, None, None, None)), + ( + { + "PnY": "1", + "PnM": "2", + "PnW": "3", + "PnD": "4", + "TnH": "5", + "TnM": "6", + "TnS": "7", + }, + DurationTuple("1", "2", "3", "4", "5", "6", "7"), + ), + ) + + for testtuple in testtuples: + self.assertEqual(TupleBuilder.build_duration(**testtuple[0]), testtuple[1]) + + def test_build_interval(self): + testtuples = ( + ({}, IntervalTuple(None, None, None)), + ( + { + "start": DateTuple("1", "2", "3", "4", "5", "6"), + "end": DateTuple("7", "8", "9", "10", "11", "12"), + }, + IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + DateTuple("7", "8", "9", "10", "11", "12"), + None, + ), + ), + ( + { + "start": TimeTuple( + "1", "2", "3", TimezoneTuple(True, False, "7", "8", "tz name") + ), + "end": TimeTuple( + "4", "5", "6", TimezoneTuple(False, False, "9", "10", "tz name") + ), + }, + IntervalTuple( + TimeTuple( + "1", "2", "3", TimezoneTuple(True, False, "7", "8", "tz name") + ), + TimeTuple( + "4", "5", "6", TimezoneTuple(False, False, "9", "10", "tz name") + ), + None, + ), + ), + ( + { + "start": DatetimeTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + TimeTuple( + "7", + "8", + "9", + TimezoneTuple(True, False, "10", "11", "tz name"), + ), + ), + "end": DatetimeTuple( + DateTuple("12", "13", "14", "15", "16", "17"), + TimeTuple( + "18", + "19", + "20", + TimezoneTuple(False, False, "21", "22", "tz name"), + ), + ), + }, + IntervalTuple( + DatetimeTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + TimeTuple( + "7", + "8", + "9", + TimezoneTuple(True, False, "10", "11", "tz name"), + ), + ), + DatetimeTuple( + DateTuple("12", "13", "14", "15", "16", "17"), + TimeTuple( + "18", + "19", + "20", + TimezoneTuple(False, False, "21", "22", "tz name"), + ), + ), + None, + ), + ), + ( + { + "start": DateTuple("1", "2", "3", "4", "5", "6"), + "end": None, + "duration": DurationTuple("7", "8", "9", "10", "11", "12", "13"), + }, + IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + None, + DurationTuple("7", "8", "9", "10", "11", "12", "13"), + ), + ), + ( + { + "start": None, + "end": TimeTuple( + "1", "2", "3", TimezoneTuple(True, False, "4", "5", "tz name") + ), + "duration": DurationTuple("6", "7", "8", "9", "10", "11", "12"), + }, + IntervalTuple( + None, + TimeTuple( + "1", "2", "3", TimezoneTuple(True, False, "4", "5", "tz name") + ), + DurationTuple("6", "7", "8", "9", "10", "11", "12"), + ), + ), + ) + + for testtuple in testtuples: + self.assertEqual(TupleBuilder.build_interval(**testtuple[0]), testtuple[1]) + + def test_build_repeating_interval(self): + testtuples = ( + ({}, RepeatingIntervalTuple(None, None, None)), + ( + { + "R": True, + "interval": IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + DateTuple("7", "8", "9", "10", "11", "12"), + None, + ), + }, + RepeatingIntervalTuple( + True, + None, + IntervalTuple( + DateTuple("1", "2", "3", "4", "5", "6"), + DateTuple("7", "8", "9", "10", "11", "12"), + None, + ), + ), + ), + ( + { + "R": False, + "Rnn": "1", + "interval": IntervalTuple( + DatetimeTuple( + DateTuple("2", "3", "4", "5", "6", "7"), + TimeTuple("8", "9", "10", None), + ), + DatetimeTuple( + DateTuple("11", "12", "13", "14", "15", "16"), + TimeTuple("17", "18", "19", None), + ), + None, + ), + }, + RepeatingIntervalTuple( + False, + "1", + IntervalTuple( + DatetimeTuple( + DateTuple("2", "3", "4", "5", "6", "7"), + TimeTuple("8", "9", "10", None), + ), + DatetimeTuple( + DateTuple("11", "12", "13", "14", "15", "16"), + TimeTuple("17", "18", "19", None), + ), + None, + ), + ), + ), + ) + + for testtuple in testtuples: + result = TupleBuilder.build_repeating_interval(**testtuple[0]) + self.assertEqual(result, testtuple[1]) + + def test_build_timezone(self): + testtuples = ( + ({}, TimezoneTuple(None, None, None, None, "")), + ( + {"negative": False, "Z": True, "name": "UTC"}, + TimezoneTuple(False, True, None, None, "UTC"), + ), + ( + {"negative": False, "Z": False, "hh": "1", "mm": "2", "name": "+01:02"}, + TimezoneTuple(False, False, "1", "2", "+01:02"), + ), + ( + {"negative": True, "Z": False, "hh": "1", "mm": "2", "name": "-01:02"}, + TimezoneTuple(True, False, "1", "2", "-01:02"), + ), + ) + + for testtuple in testtuples: + result = TupleBuilder.build_timezone(**testtuple[0]) + self.assertEqual(result, testtuple[1]) diff --git a/libs/aniso8601/builders/tests/test_python.py b/libs/aniso8601/builders/tests/test_python.py new file mode 100644 index 000000000..11111a163 --- /dev/null +++ b/libs/aniso8601/builders/tests/test_python.py @@ -0,0 +1,1710 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import datetime +import unittest + +from aniso8601 import compat +from aniso8601.builders import ( + DatetimeTuple, + DateTuple, + DurationTuple, + IntervalTuple, + Limit, + TimeTuple, + TimezoneTuple, +) +from aniso8601.builders.python import ( + FractionalComponent, + PythonTimeBuilder, + _cast_to_fractional_component, + fractional_range_check, + year_range_check, +) +from aniso8601.exceptions import ( + DayOutOfBoundsError, + HoursOutOfBoundsError, + ISOFormatError, + LeapSecondError, + MidnightBoundsError, + MinutesOutOfBoundsError, + MonthOutOfBoundsError, + SecondsOutOfBoundsError, + WeekOutOfBoundsError, + YearOutOfBoundsError, +) +from aniso8601.utcoffset import UTCOffset + + +class TestPythonTimeBuilder_UtiltyFunctions(unittest.TestCase): + def test_year_range_check(self): + yearlimit = Limit( + "Invalid year string.", + 0000, + 9999, + YearOutOfBoundsError, + "Year must be between 1..9999.", + None, + ) + + self.assertEqual(year_range_check("1", yearlimit), 1000) + + def test_fractional_range_check(self): + limit = Limit( + "Invalid string.", -1, 1, ValueError, "Value must be between -1..1.", None + ) + + self.assertEqual(fractional_range_check(10, "1", limit), 1) + self.assertEqual(fractional_range_check(10, "-1", limit), -1) + self.assertEqual( + fractional_range_check(10, "0.1", limit), FractionalComponent(0, 1) + ) + self.assertEqual( + fractional_range_check(10, "-0.1", limit), FractionalComponent(-0, 1) + ) + + with self.assertRaises(ValueError): + fractional_range_check(10, "1.1", limit) + + with self.assertRaises(ValueError): + fractional_range_check(10, "-1.1", limit) + + def test_cast_to_fractional_component(self): + self.assertEqual( + _cast_to_fractional_component(10, "1.1"), FractionalComponent(1, 1) + ) + self.assertEqual( + _cast_to_fractional_component(10, "-1.1"), FractionalComponent(-1, 1) + ) + + self.assertEqual( + _cast_to_fractional_component(100, "1.1"), FractionalComponent(1, 10) + ) + self.assertEqual( + _cast_to_fractional_component(100, "-1.1"), FractionalComponent(-1, 10) + ) + + +class TestPythonTimeBuilder(unittest.TestCase): + def test_build_date(self): + testtuples = ( + ( + { + "YYYY": "2013", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(2013, 1, 1), + ), + ( + { + "YYYY": "0001", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(1, 1, 1), + ), + ( + { + "YYYY": "1900", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(1900, 1, 1), + ), + ( + { + "YYYY": "1981", + "MM": "04", + "DD": "05", + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(1981, 4, 5), + ), + ( + { + "YYYY": "1981", + "MM": "04", + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(1981, 4, 1), + ), + ( + { + "YYYY": "1981", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": "095", + }, + datetime.date(1981, 4, 5), + ), + ( + { + "YYYY": "1981", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": "365", + }, + datetime.date(1981, 12, 31), + ), + ( + { + "YYYY": "1980", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": "366", + }, + datetime.date(1980, 12, 31), + ), + # Make sure we shift in zeros + ( + { + "YYYY": "1", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(1000, 1, 1), + ), + ( + { + "YYYY": "12", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(1200, 1, 1), + ), + ( + { + "YYYY": "123", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + datetime.date(1230, 1, 1), + ), + ) + + for testtuple in testtuples: + result = PythonTimeBuilder.build_date(**testtuple[0]) + self.assertEqual(result, testtuple[1]) + + # Test weekday + testtuples = ( + ( + { + "YYYY": "2004", + "MM": None, + "DD": None, + "Www": "53", + "D": None, + "DDD": None, + }, + datetime.date(2004, 12, 27), + 0, + ), + ( + { + "YYYY": "2009", + "MM": None, + "DD": None, + "Www": "01", + "D": None, + "DDD": None, + }, + datetime.date(2008, 12, 29), + 0, + ), + ( + { + "YYYY": "2010", + "MM": None, + "DD": None, + "Www": "01", + "D": None, + "DDD": None, + }, + datetime.date(2010, 1, 4), + 0, + ), + ( + { + "YYYY": "2009", + "MM": None, + "DD": None, + "Www": "53", + "D": None, + "DDD": None, + }, + datetime.date(2009, 12, 28), + 0, + ), + ( + { + "YYYY": "2009", + "MM": None, + "DD": None, + "Www": "01", + "D": "1", + "DDD": None, + }, + datetime.date(2008, 12, 29), + 0, + ), + ( + { + "YYYY": "2009", + "MM": None, + "DD": None, + "Www": "53", + "D": "7", + "DDD": None, + }, + datetime.date(2010, 1, 3), + 6, + ), + ( + { + "YYYY": "2010", + "MM": None, + "DD": None, + "Www": "01", + "D": "1", + "DDD": None, + }, + datetime.date(2010, 1, 4), + 0, + ), + ( + { + "YYYY": "2004", + "MM": None, + "DD": None, + "Www": "53", + "D": "6", + "DDD": None, + }, + datetime.date(2005, 1, 1), + 5, + ), + ) + + for testtuple in testtuples: + result = PythonTimeBuilder.build_date(**testtuple[0]) + self.assertEqual(result, testtuple[1]) + self.assertEqual(result.weekday(), testtuple[2]) + + def test_build_time(self): + testtuples = ( + ({}, datetime.time()), + ({"hh": "12.5"}, datetime.time(hour=12, minute=30)), + ( + {"hh": "23.99999999997"}, + datetime.time(hour=23, minute=59, second=59, microsecond=999999), + ), + ({"hh": "1", "mm": "23"}, datetime.time(hour=1, minute=23)), + ( + {"hh": "1", "mm": "23.4567"}, + datetime.time(hour=1, minute=23, second=27, microsecond=402000), + ), + ( + {"hh": "14", "mm": "43.999999997"}, + datetime.time(hour=14, minute=43, second=59, microsecond=999999), + ), + ( + {"hh": "1", "mm": "23", "ss": "45"}, + datetime.time(hour=1, minute=23, second=45), + ), + ( + {"hh": "23", "mm": "21", "ss": "28.512400"}, + datetime.time(hour=23, minute=21, second=28, microsecond=512400), + ), + ( + {"hh": "01", "mm": "03", "ss": "11.858714"}, + datetime.time(hour=1, minute=3, second=11, microsecond=858714), + ), + ( + {"hh": "14", "mm": "43", "ss": "59.9999997"}, + datetime.time(hour=14, minute=43, second=59, microsecond=999999), + ), + ({"hh": "24"}, datetime.time(hour=0)), + ({"hh": "24", "mm": "00"}, datetime.time(hour=0)), + ({"hh": "24", "mm": "00", "ss": "00"}, datetime.time(hour=0)), + ( + {"tz": TimezoneTuple(False, None, "00", "00", "UTC")}, + datetime.time(tzinfo=UTCOffset(name="UTC", minutes=0)), + ), + ( + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + datetime.time( + hour=23, + minute=21, + second=28, + microsecond=512400, + tzinfo=UTCOffset(name="+00:00", minutes=0), + ), + ), + ( + { + "hh": "1", + "mm": "23", + "tz": TimezoneTuple(False, None, "01", "00", "+1"), + }, + datetime.time( + hour=1, minute=23, tzinfo=UTCOffset(name="+1", minutes=60) + ), + ), + ( + { + "hh": "1", + "mm": "23.4567", + "tz": TimezoneTuple(True, None, "01", "00", "-1"), + }, + datetime.time( + hour=1, + minute=23, + second=27, + microsecond=402000, + tzinfo=UTCOffset(name="-1", minutes=-60), + ), + ), + ( + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "01", "30", "+1:30"), + }, + datetime.time( + hour=23, + minute=21, + second=28, + microsecond=512400, + tzinfo=UTCOffset(name="+1:30", minutes=90), + ), + ), + ( + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "11", "15", "+11:15"), + }, + datetime.time( + hour=23, + minute=21, + second=28, + microsecond=512400, + tzinfo=UTCOffset(name="+11:15", minutes=675), + ), + ), + ( + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "12", "34", "+12:34"), + }, + datetime.time( + hour=23, + minute=21, + second=28, + microsecond=512400, + tzinfo=UTCOffset(name="+12:34", minutes=754), + ), + ), + ( + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "00", "00", "UTC"), + }, + datetime.time( + hour=23, + minute=21, + second=28, + microsecond=512400, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + # https://bitbucket.org/nielsenb/aniso8601/issues/21/sub-microsecond-precision-is-lost-when + ( + {"hh": "14.9999999999999999"}, + datetime.time(hour=14, minute=59, second=59, microsecond=999999), + ), + ({"mm": "0.00000000999"}, datetime.time()), + ({"mm": "0.0000000999"}, datetime.time(microsecond=5)), + ({"ss": "0.0000001"}, datetime.time()), + ({"ss": "2.0000048"}, datetime.time(second=2, microsecond=4)), + ) + + for testtuple in testtuples: + result = PythonTimeBuilder.build_time(**testtuple[0]) + self.assertEqual(result, testtuple[1]) + + def test_build_datetime(self): + testtuples = ( + ( + ( + DateTuple("2019", "06", "05", None, None, None), + TimeTuple("01", "03", "11.858714", None), + ), + datetime.datetime( + 2019, 6, 5, hour=1, minute=3, second=11, microsecond=858714 + ), + ), + ( + ( + DateTuple("1234", "02", "03", None, None, None), + TimeTuple("23", "21", "28.512400", None), + ), + datetime.datetime( + 1234, 2, 3, hour=23, minute=21, second=28, microsecond=512400 + ), + ), + ( + ( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple( + "23", + "21", + "28.512400", + TimezoneTuple(False, None, "11", "15", "+11:15"), + ), + ), + datetime.datetime( + 1981, + 4, + 5, + hour=23, + minute=21, + second=28, + microsecond=512400, + tzinfo=UTCOffset(name="+11:15", minutes=675), + ), + ), + ) + + for testtuple in testtuples: + result = PythonTimeBuilder.build_datetime(*testtuple[0]) + self.assertEqual(result, testtuple[1]) + + def test_build_duration(self): + testtuples = ( + ( + { + "PnY": "1", + "PnM": "2", + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + datetime.timedelta(days=428, hours=4, minutes=54, seconds=6), + ), + ( + { + "PnY": "1", + "PnM": "2", + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + datetime.timedelta(days=428, hours=4, minutes=54, seconds=6.5), + ), + ({"PnY": "1", "PnM": "2", "PnD": "3"}, datetime.timedelta(days=428)), + ({"PnY": "1", "PnM": "2", "PnD": "3.5"}, datetime.timedelta(days=428.5)), + ( + {"TnH": "4", "TnM": "54", "TnS": "6.5"}, + datetime.timedelta(hours=4, minutes=54, seconds=6.5), + ), + ( + {"TnH": "1", "TnM": "3", "TnS": "11.858714"}, + datetime.timedelta(hours=1, minutes=3, seconds=11, microseconds=858714), + ), + ( + {"TnH": "4", "TnM": "54", "TnS": "28.512400"}, + datetime.timedelta( + hours=4, minutes=54, seconds=28, microseconds=512400 + ), + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + # https://bitbucket.org/nielsenb/aniso8601/issues/21/sub-microsecond-precision-is-lost-when + ( + {"PnY": "1999.9999999999999999"}, + datetime.timedelta(days=729999, seconds=86399, microseconds=999999), + ), + ( + {"PnM": "1.9999999999999999"}, + datetime.timedelta( + days=59, hours=23, minutes=59, seconds=59, microseconds=999999 + ), + ), + ( + {"PnW": "1.9999999999999999"}, + datetime.timedelta( + days=13, hours=23, minutes=59, seconds=59, microseconds=999999 + ), + ), + ( + {"PnD": "1.9999999999999999"}, + datetime.timedelta( + days=1, hours=23, minutes=59, seconds=59, microseconds=999999 + ), + ), + ( + {"TnH": "14.9999999999999999"}, + datetime.timedelta( + hours=14, minutes=59, seconds=59, microseconds=999999 + ), + ), + ({"TnM": "0.00000000999"}, datetime.timedelta(0)), + ({"TnM": "0.0000000999"}, datetime.timedelta(microseconds=5)), + ({"TnS": "0.0000001"}, datetime.timedelta(0)), + ({"TnS": "2.0000048"}, datetime.timedelta(seconds=2, microseconds=4)), + ({"PnY": "1"}, datetime.timedelta(days=365)), + ({"PnY": "1.5"}, datetime.timedelta(days=547.5)), + ({"PnM": "1"}, datetime.timedelta(days=30)), + ({"PnM": "1.5"}, datetime.timedelta(days=45)), + ({"PnW": "1"}, datetime.timedelta(days=7)), + ({"PnW": "1.5"}, datetime.timedelta(days=10.5)), + ({"PnD": "1"}, datetime.timedelta(days=1)), + ({"PnD": "1.5"}, datetime.timedelta(days=1.5)), + ( + { + "PnY": "0003", + "PnM": "06", + "PnD": "04", + "TnH": "12", + "TnM": "30", + "TnS": "05", + }, + datetime.timedelta(days=1279, hours=12, minutes=30, seconds=5), + ), + ( + { + "PnY": "0003", + "PnM": "06", + "PnD": "04", + "TnH": "12", + "TnM": "30", + "TnS": "05.5", + }, + datetime.timedelta(days=1279, hours=12, minutes=30, seconds=5.5), + ), + # Test timedelta limit + ( + {"PnD": "999999999", "TnH": "23", "TnM": "59", "TnS": "59.999999"}, + datetime.timedelta.max, + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + { + "PnY": "0001", + "PnM": "02", + "PnD": "03", + "TnH": "14", + "TnM": "43", + "TnS": "59.9999997", + }, + datetime.timedelta( + days=428, hours=14, minutes=43, seconds=59, microseconds=999999 + ), + ), + # Verify overflows + ({"TnH": "36"}, datetime.timedelta(days=1, hours=12)), + ) + + for testtuple in testtuples: + result = PythonTimeBuilder.build_duration(**testtuple[0]) + self.assertEqual(result, testtuple[1]) + + def test_build_interval(self): + testtuples = ( + ( + { + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "duration": DurationTuple(None, "1", None, None, None, None, None), + }, + datetime.datetime(year=1981, month=4, day=5, hour=1, minute=1), + datetime.datetime(year=1981, month=3, day=6, hour=1, minute=1), + ), + ( + { + "end": DateTuple("1981", "04", "05", None, None, None), + "duration": DurationTuple(None, "1", None, None, None, None, None), + }, + datetime.date(year=1981, month=4, day=5), + datetime.date(year=1981, month=3, day=6), + ), + ( + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + "1.5", None, None, None, None, None, None + ), + }, + datetime.date(year=2018, month=3, day=6), + datetime.datetime(year=2016, month=9, day=4, hour=12), + ), + ( + { + "end": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "1", None, None), + }, + datetime.date(year=2014, month=11, day=12), + datetime.datetime(year=2014, month=11, day=11, hour=23), + ), + ( + { + "end": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "4", "54", "6.5"), + }, + datetime.date(year=2014, month=11, day=12), + datetime.datetime( + year=2014, + month=11, + day=11, + hour=19, + minute=5, + second=53, + microsecond=500000, + ), + ), + ( + { + "end": DatetimeTuple( + DateTuple("2050", "03", "01", None, None, None), + TimeTuple( + "13", + "00", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "duration": DurationTuple(None, None, None, None, "10", None, None), + }, + datetime.datetime( + year=2050, + month=3, + day=1, + hour=13, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + datetime.datetime( + year=2050, + month=3, + day=1, + hour=3, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + # https://bitbucket.org/nielsenb/aniso8601/issues/21/sub-microsecond-precision-is-lost-when + ( + { + "end": DateTuple("2000", "01", "01", None, None, None), + "duration": DurationTuple( + "1999.9999999999999999", None, None, None, None, None, None + ), + }, + datetime.date(year=2000, month=1, day=1), + datetime.datetime( + year=1, month=4, day=30, hour=0, minute=0, second=0, microsecond=1 + ), + ), + ( + { + "end": DateTuple("1989", "03", "01", None, None, None), + "duration": DurationTuple( + None, "1.9999999999999999", None, None, None, None, None + ), + }, + datetime.date(year=1989, month=3, day=1), + datetime.datetime( + year=1988, + month=12, + day=31, + hour=0, + minute=0, + second=0, + microsecond=1, + ), + ), + ( + { + "end": DateTuple("1989", "03", "01", None, None, None), + "duration": DurationTuple( + None, None, "1.9999999999999999", None, None, None, None + ), + }, + datetime.date(year=1989, month=3, day=1), + datetime.datetime( + year=1989, + month=2, + day=15, + hour=0, + minute=0, + second=0, + microsecond=1, + ), + ), + ( + { + "end": DateTuple("1989", "03", "01", None, None, None), + "duration": DurationTuple( + None, None, None, "1.9999999999999999", None, None, None + ), + }, + datetime.date(year=1989, month=3, day=1), + datetime.datetime( + year=1989, + month=2, + day=27, + hour=0, + minute=0, + second=0, + microsecond=1, + ), + ), + ( + { + "end": DateTuple("2001", "01", "01", None, None, None), + "duration": DurationTuple( + None, None, None, None, "14.9999999999999999", None, None + ), + }, + datetime.date(year=2001, month=1, day=1), + datetime.datetime( + year=2000, + month=12, + day=31, + hour=9, + minute=0, + second=0, + microsecond=1, + ), + ), + ( + { + "end": DateTuple("2001", "01", "01", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, "0.00000000999", None + ), + }, + datetime.date(year=2001, month=1, day=1), + datetime.datetime(year=2001, month=1, day=1), + ), + ( + { + "end": DateTuple("2001", "01", "01", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, "0.0000000999", None + ), + }, + datetime.date(year=2001, month=1, day=1), + datetime.datetime( + year=2000, + month=12, + day=31, + hour=23, + minute=59, + second=59, + microsecond=999995, + ), + ), + ( + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "0.0000001" + ), + }, + datetime.date(year=2018, month=3, day=6), + datetime.datetime(year=2018, month=3, day=6), + ), + ( + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "2.0000048" + ), + }, + datetime.date(year=2018, month=3, day=6), + datetime.datetime( + year=2018, + month=3, + day=5, + hour=23, + minute=59, + second=57, + microsecond=999996, + ), + ), + ( + { + "start": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "duration": DurationTuple(None, "1", None, "1", None, "1", None), + }, + datetime.datetime(year=1981, month=4, day=5, hour=1, minute=1), + datetime.datetime(year=1981, month=5, day=6, hour=1, minute=2), + ), + ( + { + "start": DateTuple("1981", "04", "05", None, None, None), + "duration": DurationTuple(None, "1", None, "1", None, None, None), + }, + datetime.date(year=1981, month=4, day=5), + datetime.date(year=1981, month=5, day=6), + ), + ( + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, "2.5", None, None, None, None, None + ), + }, + datetime.date(year=2018, month=3, day=6), + datetime.date(year=2018, month=5, day=20), + ), + ( + { + "start": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "1", None, None), + }, + datetime.date(year=2014, month=11, day=12), + datetime.datetime(year=2014, month=11, day=12, hour=1, minute=0), + ), + ( + { + "start": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "4", "54", "6.5"), + }, + datetime.date(year=2014, month=11, day=12), + datetime.datetime( + year=2014, + month=11, + day=12, + hour=4, + minute=54, + second=6, + microsecond=500000, + ), + ), + ( + { + "start": DatetimeTuple( + DateTuple("2050", "03", "01", None, None, None), + TimeTuple( + "13", + "00", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "duration": DurationTuple(None, None, None, None, "10", None, None), + }, + datetime.datetime( + year=2050, + month=3, + day=1, + hour=13, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + datetime.datetime( + year=2050, + month=3, + day=1, + hour=23, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + { + "start": DateTuple("0001", "01", "01", None, None, None), + "duration": DurationTuple( + "1999.9999999999999999", None, None, None, None, None, None + ), + }, + datetime.date(year=1, month=1, day=1), + datetime.datetime( + year=1999, + month=9, + day=3, + hour=23, + minute=59, + second=59, + microsecond=999999, + ), + ), + ( + { + "start": DateTuple("1989", "03", "01", None, None, None), + "duration": DurationTuple( + None, "1.9999999999999999", None, None, None, None, None + ), + }, + datetime.date(year=1989, month=3, day=1), + datetime.datetime( + year=1989, + month=4, + day=29, + hour=23, + minute=59, + second=59, + microsecond=999999, + ), + ), + ( + { + "start": DateTuple("1989", "03", "01", None, None, None), + "duration": DurationTuple( + None, None, "1.9999999999999999", None, None, None, None + ), + }, + datetime.date(year=1989, month=3, day=1), + datetime.datetime( + year=1989, + month=3, + day=14, + hour=23, + minute=59, + second=59, + microsecond=999999, + ), + ), + ( + { + "start": DateTuple("1989", "03", "01", None, None, None), + "duration": DurationTuple( + None, None, None, "1.9999999999999999", None, None, None + ), + }, + datetime.date(year=1989, month=3, day=1), + datetime.datetime( + year=1989, + month=3, + day=2, + hour=23, + minute=59, + second=59, + microsecond=999999, + ), + ), + ( + { + "start": DateTuple("2001", "01", "01", None, None, None), + "duration": DurationTuple( + None, None, None, None, "14.9999999999999999", None, None + ), + }, + datetime.date(year=2001, month=1, day=1), + datetime.datetime( + year=2001, + month=1, + day=1, + hour=14, + minute=59, + second=59, + microsecond=999999, + ), + ), + ( + { + "start": DateTuple("2001", "01", "01", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, "0.00000000999", None + ), + }, + datetime.date(year=2001, month=1, day=1), + datetime.datetime(year=2001, month=1, day=1), + ), + ( + { + "start": DateTuple("2001", "01", "01", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, "0.0000000999", None + ), + }, + datetime.date(year=2001, month=1, day=1), + datetime.datetime( + year=2001, month=1, day=1, hour=0, minute=0, second=0, microsecond=5 + ), + ), + ( + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "0.0000001" + ), + }, + datetime.date(year=2018, month=3, day=6), + datetime.datetime(year=2018, month=3, day=6), + ), + ( + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "2.0000048" + ), + }, + datetime.date(year=2018, month=3, day=6), + datetime.datetime( + year=2018, month=3, day=6, hour=0, minute=0, second=2, microsecond=4 + ), + ), + ( + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + }, + datetime.datetime(year=1980, month=3, day=5, hour=1, minute=1), + datetime.datetime(year=1981, month=4, day=5, hour=1, minute=1), + ), + ( + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DateTuple("1981", "04", "05", None, None, None), + }, + datetime.datetime(year=1980, month=3, day=5, hour=1, minute=1), + datetime.date(year=1981, month=4, day=5), + ), + ( + { + "start": DateTuple("1980", "03", "05", None, None, None), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + }, + datetime.date(year=1980, month=3, day=5), + datetime.datetime(year=1981, month=4, day=5, hour=1, minute=1), + ), + ( + { + "start": DateTuple("1980", "03", "05", None, None, None), + "end": DateTuple("1981", "04", "05", None, None, None), + }, + datetime.date(year=1980, month=3, day=5), + datetime.date(year=1981, month=4, day=5), + ), + ( + { + "start": DateTuple("1981", "04", "05", None, None, None), + "end": DateTuple("1980", "03", "05", None, None, None), + }, + datetime.date(year=1981, month=4, day=5), + datetime.date(year=1980, month=3, day=5), + ), + ( + { + "start": DatetimeTuple( + DateTuple("2050", "03", "01", None, None, None), + TimeTuple( + "13", + "00", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "end": DatetimeTuple( + DateTuple("2050", "05", "11", None, None, None), + TimeTuple( + "15", + "30", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + }, + datetime.datetime( + year=2050, + month=3, + day=1, + hour=13, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + datetime.datetime( + year=2050, + month=5, + day=11, + hour=15, + minute=30, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + ), + # Test concise representation + ( + { + "start": DateTuple("2020", "01", "01", None, None, None), + "end": DateTuple(None, None, "02", None, None, None), + }, + datetime.date(year=2020, month=1, day=1), + datetime.date(year=2020, month=1, day=2), + ), + ( + { + "start": DateTuple("2008", "02", "15", None, None, None), + "end": DateTuple(None, "03", "14", None, None, None), + }, + datetime.date(year=2008, month=2, day=15), + datetime.date(year=2008, month=3, day=14), + ), + ( + { + "start": DatetimeTuple( + DateTuple("2007", "12", "14", None, None, None), + TimeTuple("13", "30", None, None), + ), + "end": TimeTuple("15", "30", None, None), + }, + datetime.datetime(year=2007, month=12, day=14, hour=13, minute=30), + datetime.datetime(year=2007, month=12, day=14, hour=15, minute=30), + ), + ( + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("09", "00", None, None), + ), + "end": DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + }, + datetime.datetime(year=2007, month=11, day=13, hour=9), + datetime.datetime(year=2007, month=11, day=15, hour=17), + ), + ( + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + "end": DatetimeTuple( + DateTuple(None, None, "16", None, None, None), + TimeTuple("00", "00", None, None), + ), + }, + datetime.datetime(year=2007, month=11, day=13), + datetime.datetime(year=2007, month=11, day=16), + ), + ( + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple( + "09", + "00", + None, + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "end": DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + }, + datetime.datetime( + year=2007, + month=11, + day=13, + hour=9, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + datetime.datetime( + year=2007, + month=11, + day=15, + hour=17, + tzinfo=UTCOffset(name="UTC", minutes=0), + ), + ), + ( + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("09", "00", None, None), + ), + "end": TimeTuple("12", "34.567", None, None), + }, + datetime.datetime(year=2007, month=11, day=13, hour=9), + datetime.datetime( + year=2007, + month=11, + day=13, + hour=12, + minute=34, + second=34, + microsecond=20000, + ), + ), + ( + { + "start": DateTuple("2007", "11", "13", None, None, None), + "end": TimeTuple("12", "34", None, None), + }, + datetime.date(year=2007, month=11, day=13), + datetime.datetime(year=2007, month=11, day=13, hour=12, minute=34), + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00.0000001", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("14", "43", "59.9999997", None), + ), + }, + datetime.datetime(year=1980, month=3, day=5, hour=1, minute=1), + datetime.datetime( + year=1981, + month=4, + day=5, + hour=14, + minute=43, + second=59, + microsecond=999999, + ), + ), + ) + + for testtuple in testtuples: + result = PythonTimeBuilder.build_interval(**testtuple[0]) + self.assertEqual(result[0], testtuple[1]) + self.assertEqual(result[1], testtuple[2]) + + def test_build_repeating_interval(self): + args = { + "Rnn": "3", + "interval": IntervalTuple( + DateTuple("1981", "04", "05", None, None, None), + None, + DurationTuple(None, None, None, "1", None, None, None), + ), + } + results = list(PythonTimeBuilder.build_repeating_interval(**args)) + + self.assertEqual(results[0], datetime.date(year=1981, month=4, day=5)) + self.assertEqual(results[1], datetime.date(year=1981, month=4, day=6)) + self.assertEqual(results[2], datetime.date(year=1981, month=4, day=7)) + + args = { + "Rnn": "11", + "interval": IntervalTuple( + None, + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DurationTuple(None, None, None, None, "1", "2", None), + ), + } + results = list(PythonTimeBuilder.build_repeating_interval(**args)) + + for dateindex in compat.range(0, 11): + self.assertEqual( + results[dateindex], + datetime.datetime(year=1980, month=3, day=5, hour=1, minute=1) + - dateindex * datetime.timedelta(hours=1, minutes=2), + ) + + args = { + "Rnn": "2", + "interval": IntervalTuple( + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + None, + ), + } + results = list(PythonTimeBuilder.build_repeating_interval(**args)) + + self.assertEqual( + results[0], datetime.datetime(year=1980, month=3, day=5, hour=1, minute=1) + ) + self.assertEqual( + results[1], datetime.datetime(year=1981, month=4, day=5, hour=1, minute=1) + ) + + args = { + "Rnn": "2", + "interval": IntervalTuple( + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + None, + ), + } + results = list(PythonTimeBuilder.build_repeating_interval(**args)) + + self.assertEqual( + results[0], datetime.datetime(year=1980, month=3, day=5, hour=1, minute=1) + ) + self.assertEqual( + results[1], datetime.datetime(year=1981, month=4, day=5, hour=1, minute=1) + ) + + args = { + "R": True, + "interval": IntervalTuple( + None, + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DurationTuple(None, None, None, None, "1", "2", None), + ), + } + resultgenerator = PythonTimeBuilder.build_repeating_interval(**args) + + # Test the first 11 generated + for dateindex in compat.range(0, 11): + self.assertEqual( + next(resultgenerator), + datetime.datetime(year=1980, month=3, day=5, hour=1, minute=1) + - dateindex * datetime.timedelta(hours=1, minutes=2), + ) + + args = { + "R": True, + "interval": IntervalTuple( + DateTuple("1981", "04", "05", None, None, None), + None, + DurationTuple(None, None, None, "1", None, None, None), + ), + } + resultgenerator = PythonTimeBuilder.build_repeating_interval(**args) + + # Test the first 11 generated + for dateindex in compat.range(0, 11): + self.assertEqual( + next(resultgenerator), + ( + datetime.datetime(year=1981, month=4, day=5, hour=0, minute=0) + + dateindex * datetime.timedelta(days=1) + ).date(), + ) + + def test_build_timezone(self): + testtuples = ( + ({"Z": True, "name": "Z"}, datetime.timedelta(hours=0), "UTC"), + ( + {"negative": False, "hh": "00", "mm": "00", "name": "+00:00"}, + datetime.timedelta(hours=0), + "+00:00", + ), + ( + {"negative": False, "hh": "01", "mm": "00", "name": "+01:00"}, + datetime.timedelta(hours=1), + "+01:00", + ), + ( + {"negative": True, "hh": "01", "mm": "00", "name": "-01:00"}, + -datetime.timedelta(hours=1), + "-01:00", + ), + ( + {"negative": False, "hh": "00", "mm": "12", "name": "+00:12"}, + datetime.timedelta(minutes=12), + "+00:12", + ), + ( + {"negative": False, "hh": "01", "mm": "23", "name": "+01:23"}, + datetime.timedelta(hours=1, minutes=23), + "+01:23", + ), + ( + {"negative": True, "hh": "01", "mm": "23", "name": "-01:23"}, + -datetime.timedelta(hours=1, minutes=23), + "-01:23", + ), + ( + {"negative": False, "hh": "00", "name": "+00"}, + datetime.timedelta(hours=0), + "+00", + ), + ( + {"negative": False, "hh": "01", "name": "+01"}, + datetime.timedelta(hours=1), + "+01", + ), + ( + {"negative": True, "hh": "01", "name": "-01"}, + -datetime.timedelta(hours=1), + "-01", + ), + ( + {"negative": False, "hh": "12", "name": "+12"}, + datetime.timedelta(hours=12), + "+12", + ), + ( + {"negative": True, "hh": "12", "name": "-12"}, + -datetime.timedelta(hours=12), + "-12", + ), + ) + + for testtuple in testtuples: + result = PythonTimeBuilder.build_timezone(**testtuple[0]) + self.assertEqual(result.utcoffset(None), testtuple[1]) + self.assertEqual(result.tzname(None), testtuple[2]) + + def test_range_check_date(self): + # 0 isn't a valid year for a Python builder + with self.assertRaises(YearOutOfBoundsError): + PythonTimeBuilder.build_date(YYYY="0000") + + # Leap year + # https://bitbucket.org/nielsenb/aniso8601/issues/14/parsing-ordinal-dates-should-only-allow + with self.assertRaises(DayOutOfBoundsError): + PythonTimeBuilder.build_date(YYYY="1981", DDD="366") + + def test_range_check_time(self): + # Hour 24 can only represent midnight + with self.assertRaises(MidnightBoundsError): + PythonTimeBuilder.build_time(hh="24", mm="00", ss="01") + + with self.assertRaises(MidnightBoundsError): + PythonTimeBuilder.build_time(hh="24", mm="00.1") + + with self.assertRaises(MidnightBoundsError): + PythonTimeBuilder.build_time(hh="24", mm="01") + + with self.assertRaises(MidnightBoundsError): + PythonTimeBuilder.build_time(hh="24.1") + + def test_range_check_duration(self): + with self.assertRaises(YearOutOfBoundsError): + PythonTimeBuilder.build_duration( + PnY=str((datetime.timedelta.max.days // 365) + 1) + ) + + with self.assertRaises(MonthOutOfBoundsError): + PythonTimeBuilder.build_duration( + PnM=str((datetime.timedelta.max.days // 30) + 1) + ) + + with self.assertRaises(DayOutOfBoundsError): + PythonTimeBuilder.build_duration(PnD=str(datetime.timedelta.max.days + 1)) + + with self.assertRaises(WeekOutOfBoundsError): + PythonTimeBuilder.build_duration( + PnW=str((datetime.timedelta.max.days // 7) + 1) + ) + + with self.assertRaises(HoursOutOfBoundsError): + PythonTimeBuilder.build_duration( + TnH=str((datetime.timedelta.max.days * 24) + 24) + ) + + with self.assertRaises(MinutesOutOfBoundsError): + PythonTimeBuilder.build_duration( + TnM=str((datetime.timedelta.max.days * 24 * 60) + 24 * 60) + ) + + with self.assertRaises(SecondsOutOfBoundsError): + PythonTimeBuilder.build_duration( + TnS=str((datetime.timedelta.max.days * 24 * 60 * 60) + 24 * 60 * 60) + ) + + # Split max range across all parts + maxpart = datetime.timedelta.max.days // 7 + + with self.assertRaises(DayOutOfBoundsError): + PythonTimeBuilder.build_duration( + PnY=str((maxpart // 365) + 1), + PnM=str((maxpart // 30) + 1), + PnD=str((maxpart + 1)), + PnW=str((maxpart // 7) + 1), + TnH=str((maxpart * 24) + 1), + TnM=str((maxpart * 24 * 60) + 1), + TnS=str((maxpart * 24 * 60 * 60) + 1), + ) + + def test_range_check_interval(self): + with self.assertRaises(YearOutOfBoundsError): + PythonTimeBuilder.build_interval( + start=DateTuple("0007", None, None, None, None, None), + duration=DurationTuple( + None, None, None, str(datetime.timedelta.max.days), None, None, None + ), + ) + + with self.assertRaises(YearOutOfBoundsError): + PythonTimeBuilder.build_interval( + start=DatetimeTuple( + DateTuple("0007", None, None, None, None, None), + TimeTuple("1", None, None, None), + ), + duration=DurationTuple( + str(datetime.timedelta.max.days // 365), + None, + None, + None, + None, + None, + None, + ), + ) + + with self.assertRaises(YearOutOfBoundsError): + PythonTimeBuilder.build_interval( + end=DateTuple("0001", None, None, None, None, None), + duration=DurationTuple("3", None, None, None, None, None, None), + ) + + with self.assertRaises(YearOutOfBoundsError): + PythonTimeBuilder.build_interval( + end=DatetimeTuple( + DateTuple("0001", None, None, None, None, None), + TimeTuple("1", None, None, None), + ), + duration=DurationTuple("2", None, None, None, None, None, None), + ) + + def test_build_week_date(self): + weekdate = PythonTimeBuilder._build_week_date(2009, 1) + self.assertEqual(weekdate, datetime.date(year=2008, month=12, day=29)) + + weekdate = PythonTimeBuilder._build_week_date(2009, 53, isoday=7) + self.assertEqual(weekdate, datetime.date(year=2010, month=1, day=3)) + + def test_build_ordinal_date(self): + ordinaldate = PythonTimeBuilder._build_ordinal_date(1981, 95) + self.assertEqual(ordinaldate, datetime.date(year=1981, month=4, day=5)) + + def test_iso_year_start(self): + yearstart = PythonTimeBuilder._iso_year_start(2004) + self.assertEqual(yearstart, datetime.date(year=2003, month=12, day=29)) + + yearstart = PythonTimeBuilder._iso_year_start(2010) + self.assertEqual(yearstart, datetime.date(year=2010, month=1, day=4)) + + yearstart = PythonTimeBuilder._iso_year_start(2009) + self.assertEqual(yearstart, datetime.date(year=2008, month=12, day=29)) + + def test_date_generator(self): + startdate = datetime.date(year=2018, month=8, day=29) + timedelta = datetime.timedelta(days=1) + iterations = 10 + + generator = PythonTimeBuilder._date_generator(startdate, timedelta, iterations) + + results = list(generator) + + for dateindex in compat.range(0, 10): + self.assertEqual( + results[dateindex], + datetime.date(year=2018, month=8, day=29) + + dateindex * datetime.timedelta(days=1), + ) + + def test_date_generator_unbounded(self): + startdate = datetime.date(year=2018, month=8, day=29) + timedelta = datetime.timedelta(days=5) + + generator = PythonTimeBuilder._date_generator_unbounded(startdate, timedelta) + + # Check the first 10 results + for dateindex in compat.range(0, 10): + self.assertEqual( + next(generator), + datetime.date(year=2018, month=8, day=29) + + dateindex * datetime.timedelta(days=5), + ) + + def test_distribute_microseconds(self): + self.assertEqual(PythonTimeBuilder._distribute_microseconds(1, (), ()), (1,)) + self.assertEqual( + PythonTimeBuilder._distribute_microseconds(11, (0,), (10,)), (1, 1) + ) + self.assertEqual( + PythonTimeBuilder._distribute_microseconds(211, (0, 0), (100, 10)), + (2, 1, 1), + ) + + self.assertEqual(PythonTimeBuilder._distribute_microseconds(1, (), ()), (1,)) + self.assertEqual( + PythonTimeBuilder._distribute_microseconds(11, (5,), (10,)), (6, 1) + ) + self.assertEqual( + PythonTimeBuilder._distribute_microseconds(211, (10, 5), (100, 10)), + (12, 6, 1), + ) diff --git a/libs/aniso8601/compat.py b/libs/aniso8601/compat.py new file mode 100644 index 000000000..25af5794a --- /dev/null +++ b/libs/aniso8601/compat.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import sys + +PY2 = sys.version_info[0] == 2 + +if PY2: # pragma: no cover + range = xrange # pylint: disable=undefined-variable +else: + range = range + + +def is_string(tocheck): + # pylint: disable=undefined-variable + if PY2: # pragma: no cover + return isinstance(tocheck, str) or isinstance(tocheck, unicode) + + return isinstance(tocheck, str) diff --git a/libs/aniso8601/date.py b/libs/aniso8601/date.py new file mode 100644 index 000000000..ea0cf9c59 --- /dev/null +++ b/libs/aniso8601/date.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +from aniso8601.builders import TupleBuilder +from aniso8601.builders.python import PythonTimeBuilder +from aniso8601.compat import is_string +from aniso8601.exceptions import ISOFormatError +from aniso8601.resolution import DateResolution + + +def get_date_resolution(isodatestr): + # Valid string formats are: + # + # Y[YYY] + # YYYY-MM-DD + # YYYYMMDD + # YYYY-MM + # YYYY-Www + # YYYYWww + # YYYY-Www-D + # YYYYWwwD + # YYYY-DDD + # YYYYDDD + isodatetuple = parse_date(isodatestr, builder=TupleBuilder) + + if isodatetuple.DDD is not None: + # YYYY-DDD + # YYYYDDD + return DateResolution.Ordinal + + if isodatetuple.D is not None: + # YYYY-Www-D + # YYYYWwwD + return DateResolution.Weekday + + if isodatetuple.Www is not None: + # YYYY-Www + # YYYYWww + return DateResolution.Week + + if isodatetuple.DD is not None: + # YYYY-MM-DD + # YYYYMMDD + return DateResolution.Day + + if isodatetuple.MM is not None: + # YYYY-MM + return DateResolution.Month + + # Y[YYY] + return DateResolution.Year + + +def parse_date(isodatestr, builder=PythonTimeBuilder): + # Given a string in any ISO 8601 date format, return a datetime.date + # object that corresponds to the given date. Valid string formats are: + # + # Y[YYY] + # YYYY-MM-DD + # YYYYMMDD + # YYYY-MM + # YYYY-Www + # YYYYWww + # YYYY-Www-D + # YYYYWwwD + # YYYY-DDD + # YYYYDDD + if is_string(isodatestr) is False: + raise ValueError("Date must be string.") + + if isodatestr.startswith("+") or isodatestr.startswith("-"): + raise NotImplementedError( + "ISO 8601 extended year representation " "not supported." + ) + + if len(isodatestr) == 0 or isodatestr.count("-") > 2: + raise ISOFormatError('"{0}" is not a valid ISO 8601 date.'.format(isodatestr)) + yearstr = None + monthstr = None + daystr = None + weekstr = None + weekdaystr = None + ordinaldaystr = None + + if len(isodatestr) <= 4: + # Y[YYY] + yearstr = isodatestr + elif "W" in isodatestr: + if len(isodatestr) == 10: + # YYYY-Www-D + yearstr = isodatestr[0:4] + weekstr = isodatestr[6:8] + weekdaystr = isodatestr[9] + elif len(isodatestr) == 8: + if "-" in isodatestr: + # YYYY-Www + yearstr = isodatestr[0:4] + weekstr = isodatestr[6:] + else: + # YYYYWwwD + yearstr = isodatestr[0:4] + weekstr = isodatestr[5:7] + weekdaystr = isodatestr[7] + elif len(isodatestr) == 7: + # YYYYWww + yearstr = isodatestr[0:4] + weekstr = isodatestr[5:] + elif len(isodatestr) == 7: + if "-" in isodatestr: + # YYYY-MM + yearstr = isodatestr[0:4] + monthstr = isodatestr[5:] + else: + # YYYYDDD + yearstr = isodatestr[0:4] + ordinaldaystr = isodatestr[4:] + elif len(isodatestr) == 8: + if "-" in isodatestr: + # YYYY-DDD + yearstr = isodatestr[0:4] + ordinaldaystr = isodatestr[5:] + else: + # YYYYMMDD + yearstr = isodatestr[0:4] + monthstr = isodatestr[4:6] + daystr = isodatestr[6:] + elif len(isodatestr) == 10: + # YYYY-MM-DD + yearstr = isodatestr[0:4] + monthstr = isodatestr[5:7] + daystr = isodatestr[8:] + else: + raise ISOFormatError('"{0}" is not a valid ISO 8601 date.'.format(isodatestr)) + + hascomponent = False + + for componentstr in [yearstr, monthstr, daystr, weekstr, weekdaystr, ordinaldaystr]: + if componentstr is not None: + hascomponent = True + + if componentstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 date.'.format(isodatestr) + ) + + if hascomponent is False: + raise ISOFormatError('"{0}" is not a valid ISO 8601 date.'.format(isodatestr)) + + return builder.build_date( + YYYY=yearstr, + MM=monthstr, + DD=daystr, + Www=weekstr, + D=weekdaystr, + DDD=ordinaldaystr, + ) diff --git a/libs/aniso8601/decimalfraction.py b/libs/aniso8601/decimalfraction.py new file mode 100644 index 000000000..3086ee794 --- /dev/null +++ b/libs/aniso8601/decimalfraction.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + + +def normalize(value): + """Returns the string with decimal separators normalized.""" + return value.replace(",", ".") diff --git a/libs/aniso8601/duration.py b/libs/aniso8601/duration.py new file mode 100644 index 000000000..cdc0f8f7f --- /dev/null +++ b/libs/aniso8601/duration.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +from aniso8601 import compat +from aniso8601.builders import TupleBuilder +from aniso8601.builders.python import PythonTimeBuilder +from aniso8601.date import parse_date +from aniso8601.decimalfraction import normalize +from aniso8601.exceptions import ISOFormatError +from aniso8601.resolution import DurationResolution +from aniso8601.time import parse_time + + +def get_duration_resolution(isodurationstr): + # Valid string formats are: + # + # PnYnMnDTnHnMnS (or any reduced precision equivalent) + # PnW + # P<date>T<time> + isodurationtuple = parse_duration(isodurationstr, builder=TupleBuilder) + + if isodurationtuple.TnS is not None: + return DurationResolution.Seconds + + if isodurationtuple.TnM is not None: + return DurationResolution.Minutes + + if isodurationtuple.TnH is not None: + return DurationResolution.Hours + + if isodurationtuple.PnD is not None: + return DurationResolution.Days + + if isodurationtuple.PnW is not None: + return DurationResolution.Weeks + + if isodurationtuple.PnM is not None: + return DurationResolution.Months + + return DurationResolution.Years + + +def parse_duration(isodurationstr, builder=PythonTimeBuilder): + # Given a string representing an ISO 8601 duration, return a + # a duration built by the given builder. Valid formats are: + # + # PnYnMnDTnHnMnS (or any reduced precision equivalent) + # PnW + # P<date>T<time> + + if compat.is_string(isodurationstr) is False: + raise ValueError("Duration must be string.") + + if len(isodurationstr) == 0: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + + if isodurationstr[0] != "P": + raise ISOFormatError("ISO 8601 duration must start with a P.") + + # If Y, M, D, H, S, or W are in the string, + # assume it is a specified duration + if _has_any_component(isodurationstr, ["Y", "M", "D", "H", "S", "W"]) is True: + parseresult = _parse_duration_prescribed(isodurationstr) + return builder.build_duration(**parseresult) + + if isodurationstr.find("T") != -1: + parseresult = _parse_duration_combined(isodurationstr) + return builder.build_duration(**parseresult) + + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + + +def _parse_duration_prescribed(isodurationstr): + # durationstr can be of the form PnYnMnDTnHnMnS or PnW + + # Make sure the end character is valid + # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed + if isodurationstr[-1] not in ["Y", "M", "D", "H", "S", "W"]: + raise ISOFormatError("ISO 8601 duration must end with a valid " "character.") + + # Make sure only the lowest order element has decimal precision + durationstr = normalize(isodurationstr) + + if durationstr.count(".") > 1: + raise ISOFormatError( + "ISO 8601 allows only lowest order element to " "have a decimal fraction." + ) + + seperatoridx = durationstr.find(".") + + if seperatoridx != -1: + remaining = durationstr[seperatoridx + 1 : -1] + + # There should only ever be 1 letter after a decimal if there is more + # then one, the string is invalid + if remaining.isdigit() is False: + raise ISOFormatError( + "ISO 8601 duration must end with " "a single valid character." + ) + + # Do not allow W in combination with other designators + # https://bitbucket.org/nielsenb/aniso8601/issues/2/week-designators-should-not-be-combinable + if ( + durationstr.find("W") != -1 + and _has_any_component(durationstr, ["Y", "M", "D", "H", "S"]) is True + ): + raise ISOFormatError( + "ISO 8601 week designators may not be combined " + "with other time designators." + ) + + # Parse the elements of the duration + if durationstr.find("T") == -1: + return _parse_duration_prescribed_notime(durationstr) + + return _parse_duration_prescribed_time(durationstr) + + +def _parse_duration_prescribed_notime(isodurationstr): + # durationstr can be of the form PnYnMnD or PnW + + durationstr = normalize(isodurationstr) + + yearstr = None + monthstr = None + daystr = None + weekstr = None + + weekidx = durationstr.find("W") + yearidx = durationstr.find("Y") + monthidx = durationstr.find("M") + dayidx = durationstr.find("D") + + if weekidx != -1: + weekstr = durationstr[1:-1] + elif yearidx != -1 and monthidx != -1 and dayidx != -1: + yearstr = durationstr[1:yearidx] + monthstr = durationstr[yearidx + 1 : monthidx] + daystr = durationstr[monthidx + 1 : -1] + elif yearidx != -1 and monthidx != -1: + yearstr = durationstr[1:yearidx] + monthstr = durationstr[yearidx + 1 : monthidx] + elif yearidx != -1 and dayidx != -1: + yearstr = durationstr[1:yearidx] + daystr = durationstr[yearidx + 1 : dayidx] + elif monthidx != -1 and dayidx != -1: + monthstr = durationstr[1:monthidx] + daystr = durationstr[monthidx + 1 : -1] + elif yearidx != -1: + yearstr = durationstr[1:-1] + elif monthidx != -1: + monthstr = durationstr[1:-1] + elif dayidx != -1: + daystr = durationstr[1:-1] + else: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + + for componentstr in [yearstr, monthstr, daystr, weekstr]: + if componentstr is not None: + if "." in componentstr: + intstr, fractionalstr = componentstr.split(".", 1) + + if intstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + else: + if componentstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + + return {"PnY": yearstr, "PnM": monthstr, "PnW": weekstr, "PnD": daystr} + + +def _parse_duration_prescribed_time(isodurationstr): + # durationstr can be of the form PnYnMnDTnHnMnS + + timeidx = isodurationstr.find("T") + + datestr = isodurationstr[:timeidx] + timestr = normalize(isodurationstr[timeidx + 1 :]) + + hourstr = None + minutestr = None + secondstr = None + + houridx = timestr.find("H") + minuteidx = timestr.find("M") + secondidx = timestr.find("S") + + if houridx != -1 and minuteidx != -1 and secondidx != -1: + hourstr = timestr[0:houridx] + minutestr = timestr[houridx + 1 : minuteidx] + secondstr = timestr[minuteidx + 1 : -1] + elif houridx != -1 and minuteidx != -1: + hourstr = timestr[0:houridx] + minutestr = timestr[houridx + 1 : minuteidx] + elif houridx != -1 and secondidx != -1: + hourstr = timestr[0:houridx] + secondstr = timestr[houridx + 1 : -1] + elif minuteidx != -1 and secondidx != -1: + minutestr = timestr[0:minuteidx] + secondstr = timestr[minuteidx + 1 : -1] + elif houridx != -1: + hourstr = timestr[0:-1] + elif minuteidx != -1: + minutestr = timestr[0:-1] + elif secondidx != -1: + secondstr = timestr[0:-1] + else: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + + for componentstr in [hourstr, minutestr, secondstr]: + if componentstr is not None: + if "." in componentstr: + intstr, fractionalstr = componentstr.split(".", 1) + + if intstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + else: + if componentstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) + ) + + # Parse any date components + durationdict = {"PnY": None, "PnM": None, "PnW": None, "PnD": None} + + if len(datestr) > 1: + durationdict = _parse_duration_prescribed_notime(datestr) + + durationdict.update({"TnH": hourstr, "TnM": minutestr, "TnS": secondstr}) + + return durationdict + + +def _parse_duration_combined(durationstr): + # Period of the form P<date>T<time> + + # Split the string in to its component parts + datepart, timepart = durationstr[1:].split("T", 1) # We skip the 'P' + + datevalue = parse_date(datepart, builder=TupleBuilder) + timevalue = parse_time(timepart, builder=TupleBuilder) + + return { + "PnY": datevalue.YYYY, + "PnM": datevalue.MM, + "PnD": datevalue.DD, + "TnH": timevalue.hh, + "TnM": timevalue.mm, + "TnS": timevalue.ss, + } + + +def _has_any_component(durationstr, components): + # Given a duration string, and a list of components, returns True + # if any of the listed components are present, False otherwise. + # + # For instance: + # durationstr = 'P1Y' + # components = ['Y', 'M'] + # + # returns True + # + # durationstr = 'P1Y' + # components = ['M', 'D'] + # + # returns False + + for component in components: + if durationstr.find(component) != -1: + return True + + return False diff --git a/libs/aniso8601/exceptions.py b/libs/aniso8601/exceptions.py new file mode 100644 index 000000000..c14b44421 --- /dev/null +++ b/libs/aniso8601/exceptions.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + + +class ISOFormatError(ValueError): + """Raised when ISO 8601 string fails a format check.""" + + +class RangeCheckError(ValueError): + """Parent type of range check errors.""" + + +class YearOutOfBoundsError(RangeCheckError): + """Raised when year exceeds limits.""" + + +class MonthOutOfBoundsError(RangeCheckError): + """Raised when month is outside of 1..12.""" + + +class WeekOutOfBoundsError(RangeCheckError): + """Raised when week exceeds a year.""" + + +class DayOutOfBoundsError(RangeCheckError): + """Raised when day is outside of 1..365, 1..366 for leap year.""" + + +class HoursOutOfBoundsError(RangeCheckError): + """Raise when parsed hours are greater than 24.""" + + +class MinutesOutOfBoundsError(RangeCheckError): + """Raise when parsed seconds are greater than 60.""" + + +class SecondsOutOfBoundsError(RangeCheckError): + """Raise when parsed seconds are greater than 60.""" + + +class MidnightBoundsError(RangeCheckError): + """Raise when parsed time has an hour of 24 but is not midnight.""" + + +class LeapSecondError(RangeCheckError): + """Raised when attempting to parse a leap second""" diff --git a/libs/aniso8601/interval.py b/libs/aniso8601/interval.py new file mode 100644 index 000000000..cd0184c6b --- /dev/null +++ b/libs/aniso8601/interval.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +from aniso8601.builders import DatetimeTuple, DateTuple, TupleBuilder +from aniso8601.builders.python import PythonTimeBuilder +from aniso8601.compat import is_string +from aniso8601.date import parse_date +from aniso8601.duration import parse_duration +from aniso8601.exceptions import ISOFormatError +from aniso8601.resolution import IntervalResolution +from aniso8601.time import parse_datetime, parse_time + + +def get_interval_resolution( + isointervalstr, intervaldelimiter="/", datetimedelimiter="T" +): + isointervaltuple = parse_interval( + isointervalstr, + intervaldelimiter=intervaldelimiter, + datetimedelimiter=datetimedelimiter, + builder=TupleBuilder, + ) + + return _get_interval_resolution(isointervaltuple) + + +def get_repeating_interval_resolution( + isointervalstr, intervaldelimiter="/", datetimedelimiter="T" +): + repeatingintervaltuple = parse_repeating_interval( + isointervalstr, + intervaldelimiter=intervaldelimiter, + datetimedelimiter=datetimedelimiter, + builder=TupleBuilder, + ) + + return _get_interval_resolution(repeatingintervaltuple.interval) + + +def _get_interval_resolution(intervaltuple): + if intervaltuple.start is not None and intervaltuple.end is not None: + return max( + _get_interval_component_resolution(intervaltuple.start), + _get_interval_component_resolution(intervaltuple.end), + ) + + if intervaltuple.start is not None and intervaltuple.duration is not None: + return max( + _get_interval_component_resolution(intervaltuple.start), + _get_interval_component_resolution(intervaltuple.duration), + ) + + return max( + _get_interval_component_resolution(intervaltuple.end), + _get_interval_component_resolution(intervaltuple.duration), + ) + + +def _get_interval_component_resolution(componenttuple): + if type(componenttuple) is DateTuple: + if componenttuple.DDD is not None: + # YYYY-DDD + # YYYYDDD + return IntervalResolution.Ordinal + + if componenttuple.D is not None: + # YYYY-Www-D + # YYYYWwwD + return IntervalResolution.Weekday + + if componenttuple.Www is not None: + # YYYY-Www + # YYYYWww + return IntervalResolution.Week + + if componenttuple.DD is not None: + # YYYY-MM-DD + # YYYYMMDD + return IntervalResolution.Day + + if componenttuple.MM is not None: + # YYYY-MM + return IntervalResolution.Month + + # Y[YYY] + return IntervalResolution.Year + elif type(componenttuple) is DatetimeTuple: + # Datetime + if componenttuple.time.ss is not None: + return IntervalResolution.Seconds + + if componenttuple.time.mm is not None: + return IntervalResolution.Minutes + + return IntervalResolution.Hours + + # Duration + if componenttuple.TnS is not None: + return IntervalResolution.Seconds + + if componenttuple.TnM is not None: + return IntervalResolution.Minutes + + if componenttuple.TnH is not None: + return IntervalResolution.Hours + + if componenttuple.PnD is not None: + return IntervalResolution.Day + + if componenttuple.PnW is not None: + return IntervalResolution.Week + + if componenttuple.PnM is not None: + return IntervalResolution.Month + + return IntervalResolution.Year + + +def parse_interval( + isointervalstr, + intervaldelimiter="/", + datetimedelimiter="T", + builder=PythonTimeBuilder, +): + # Given a string representing an ISO 8601 interval, return an + # interval built by the given builder. Valid formats are: + # + # <start>/<end> + # <start>/<duration> + # <duration>/<end> + # + # The <start> and <end> values can represent dates, or datetimes, + # not times. + # + # The format: + # + # <duration> + # + # Is expressly not supported as there is no way to provide the additional + # required context. + + if is_string(isointervalstr) is False: + raise ValueError("Interval must be string.") + + if len(isointervalstr) == 0: + raise ISOFormatError("Interval string is empty.") + + if isointervalstr[0] == "R": + raise ISOFormatError( + "ISO 8601 repeating intervals must be parsed " + "with parse_repeating_interval." + ) + + intervaldelimitercount = isointervalstr.count(intervaldelimiter) + + if intervaldelimitercount == 0: + raise ISOFormatError( + 'Interval delimiter "{0}" is not in interval ' + 'string "{1}".'.format(intervaldelimiter, isointervalstr) + ) + + if intervaldelimitercount > 1: + raise ISOFormatError( + "{0} is not a valid ISO 8601 interval".format(isointervalstr) + ) + + return _parse_interval( + isointervalstr, builder, intervaldelimiter, datetimedelimiter + ) + + +def parse_repeating_interval( + isointervalstr, + intervaldelimiter="/", + datetimedelimiter="T", + builder=PythonTimeBuilder, +): + # Given a string representing an ISO 8601 interval repeating, return an + # interval built by the given builder. Valid formats are: + # + # Rnn/<interval> + # R/<interval> + + if not isinstance(isointervalstr, str): + raise ValueError("Interval must be string.") + + if len(isointervalstr) == 0: + raise ISOFormatError("Repeating interval string is empty.") + + if isointervalstr[0] != "R": + raise ISOFormatError("ISO 8601 repeating interval must start " "with an R.") + + if intervaldelimiter not in isointervalstr: + raise ISOFormatError( + 'Interval delimiter "{0}" is not in interval ' + 'string "{1}".'.format(intervaldelimiter, isointervalstr) + ) + + # Parse the number of iterations + iterationpart, intervalpart = isointervalstr.split(intervaldelimiter, 1) + + if len(iterationpart) > 1: + R = False + Rnn = iterationpart[1:] + else: + R = True + Rnn = None + + interval = _parse_interval( + intervalpart, TupleBuilder, intervaldelimiter, datetimedelimiter + ) + + return builder.build_repeating_interval(R=R, Rnn=Rnn, interval=interval) + + +def _parse_interval( + isointervalstr, builder, intervaldelimiter="/", datetimedelimiter="T" +): + # Returns a tuple containing the start of the interval, the end of the + # interval, and or the interval duration + + firstpart, secondpart = isointervalstr.split(intervaldelimiter) + + if len(firstpart) == 0 or len(secondpart) == 0: + raise ISOFormatError( + "{0} is not a valid ISO 8601 interval".format(isointervalstr) + ) + + if firstpart[0] == "P": + # <duration>/<end> + # Notice that these are not returned 'in order' (earlier to later), this + # is to maintain consistency with parsing <start>/<end> durations, as + # well as making repeating interval code cleaner. Users who desire + # durations to be in order can use the 'sorted' operator. + duration = parse_duration(firstpart, builder=TupleBuilder) + + # We need to figure out if <end> is a date, or a datetime + if secondpart.find(datetimedelimiter) != -1: + # <end> is a datetime + endtuple = parse_datetime( + secondpart, delimiter=datetimedelimiter, builder=TupleBuilder + ) + else: + endtuple = parse_date(secondpart, builder=TupleBuilder) + + return builder.build_interval(end=endtuple, duration=duration) + elif secondpart[0] == "P": + # <start>/<duration> + # We need to figure out if <start> is a date, or a datetime + duration = parse_duration(secondpart, builder=TupleBuilder) + + if firstpart.find(datetimedelimiter) != -1: + # <start> is a datetime + starttuple = parse_datetime( + firstpart, delimiter=datetimedelimiter, builder=TupleBuilder + ) + else: + # <start> must just be a date + starttuple = parse_date(firstpart, builder=TupleBuilder) + + return builder.build_interval(start=starttuple, duration=duration) + + # <start>/<end> + if firstpart.find(datetimedelimiter) != -1: + # Both parts are datetimes + starttuple = parse_datetime( + firstpart, delimiter=datetimedelimiter, builder=TupleBuilder + ) + else: + starttuple = parse_date(firstpart, builder=TupleBuilder) + + endtuple = _parse_interval_end(secondpart, starttuple, datetimedelimiter) + + return builder.build_interval(start=starttuple, end=endtuple) + + +def _parse_interval_end(endstr, starttuple, datetimedelimiter): + datestr = None + timestr = None + + monthstr = None + daystr = None + + concise = False + + if type(starttuple) is DateTuple: + startdatetuple = starttuple + else: + # Start is a datetime + startdatetuple = starttuple.date + + if datetimedelimiter in endstr: + datestr, timestr = endstr.split(datetimedelimiter, 1) + elif ":" in endstr: + timestr = endstr + else: + datestr = endstr + + if timestr is not None: + endtimetuple = parse_time(timestr, builder=TupleBuilder) + + # End is just a time + if datestr is None: + return endtimetuple + + # Handle backwards concise representation + if datestr.count("-") == 1: + monthstr, daystr = datestr.split("-") + concise = True + elif len(datestr) <= 2: + daystr = datestr + concise = True + elif len(datestr) <= 4: + monthstr = datestr[0:2] + daystr = datestr[2:] + concise = True + + if concise is True: + concisedatestr = startdatetuple.YYYY + + # Separators required because concise elements may be missing digits + if monthstr is not None: + concisedatestr += "-" + monthstr + elif startdatetuple.MM is not None: + concisedatestr += "-" + startdatetuple.MM + + concisedatestr += "-" + daystr + + enddatetuple = parse_date(concisedatestr, builder=TupleBuilder) + + # Clear unsupplied components + if monthstr is None: + enddatetuple = TupleBuilder.build_date(DD=enddatetuple.DD) + else: + # Year not provided + enddatetuple = TupleBuilder.build_date( + MM=enddatetuple.MM, DD=enddatetuple.DD + ) + else: + enddatetuple = parse_date(datestr, builder=TupleBuilder) + + if timestr is None: + return enddatetuple + + return TupleBuilder.build_datetime(enddatetuple, endtimetuple) diff --git a/libs/aniso8601/resolution.py b/libs/aniso8601/resolution.py new file mode 100644 index 000000000..eca2d4e7e --- /dev/null +++ b/libs/aniso8601/resolution.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +from aniso8601 import compat + + +class DateResolution(object): + Year, Month, Week, Weekday, Day, Ordinal = list(compat.range(6)) + + +class DurationResolution(object): + Years, Months, Weeks, Days, Hours, Minutes, Seconds = list(compat.range(7)) + + +class IntervalResolution(object): + Year, Month, Week, Weekday, Day, Ordinal, Hours, Minutes, Seconds = list( + compat.range(9) + ) + + +class TimeResolution(object): + Seconds, Minutes, Hours = list(compat.range(3)) diff --git a/libs/aniso8601/tests/__init__.py b/libs/aniso8601/tests/__init__.py new file mode 100644 index 000000000..1a94e017a --- /dev/null +++ b/libs/aniso8601/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. diff --git a/libs/aniso8601/tests/compat.py b/libs/aniso8601/tests/compat.py new file mode 100644 index 000000000..6c5266589 --- /dev/null +++ b/libs/aniso8601/tests/compat.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import sys + +PY2 = sys.version_info[0] == 2 + +if PY2: + import mock # pylint: disable=import-error +else: + from unittest import mock diff --git a/libs/aniso8601/tests/test_compat.py b/libs/aniso8601/tests/test_compat.py new file mode 100644 index 000000000..2f7988331 --- /dev/null +++ b/libs/aniso8601/tests/test_compat.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +from aniso8601.compat import PY2, is_string + + +class TestCompatFunctions(unittest.TestCase): + def test_is_string(self): + self.assertTrue(is_string("asdf")) + self.assertTrue(is_string("")) + + # pylint: disable=undefined-variable + if PY2 is True: + self.assertTrue(is_string(unicode("asdf"))) + + self.assertFalse(is_string(None)) + self.assertFalse(is_string(123)) + self.assertFalse(is_string(4.56)) + self.assertFalse(is_string([])) + self.assertFalse(is_string({})) diff --git a/libs/aniso8601/tests/test_date.py b/libs/aniso8601/tests/test_date.py new file mode 100644 index 000000000..54e3076eb --- /dev/null +++ b/libs/aniso8601/tests/test_date.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +import aniso8601 +from aniso8601.date import get_date_resolution, parse_date +from aniso8601.exceptions import DayOutOfBoundsError, ISOFormatError +from aniso8601.resolution import DateResolution +from aniso8601.tests.compat import mock + + +class TestDateResolutionFunctions(unittest.TestCase): + def test_get_date_resolution_year(self): + self.assertEqual(get_date_resolution("2013"), DateResolution.Year) + self.assertEqual(get_date_resolution("0001"), DateResolution.Year) + self.assertEqual(get_date_resolution("19"), DateResolution.Year) + + def test_get_date_resolution_month(self): + self.assertEqual(get_date_resolution("1981-04"), DateResolution.Month) + + def test_get_date_resolution_week(self): + self.assertEqual(get_date_resolution("2004-W53"), DateResolution.Week) + self.assertEqual(get_date_resolution("2009-W01"), DateResolution.Week) + self.assertEqual(get_date_resolution("2004W53"), DateResolution.Week) + + def test_get_date_resolution_day(self): + self.assertEqual(get_date_resolution("2004-04-11"), DateResolution.Day) + self.assertEqual(get_date_resolution("20090121"), DateResolution.Day) + + def test_get_date_resolution_year_weekday(self): + self.assertEqual(get_date_resolution("2004-W53-6"), DateResolution.Weekday) + self.assertEqual(get_date_resolution("2004W536"), DateResolution.Weekday) + + def test_get_date_resolution_year_ordinal(self): + self.assertEqual(get_date_resolution("1981-095"), DateResolution.Ordinal) + self.assertEqual(get_date_resolution("1981095"), DateResolution.Ordinal) + + def test_get_date_resolution_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + get_date_resolution(testtuple) + + def test_get_date_resolution_extended_year(self): + testtuples = ("+2000", "+30000") + + for testtuple in testtuples: + with self.assertRaises(NotImplementedError): + get_date_resolution(testtuple) + + def test_get_date_resolution_badweek(self): + testtuples = ("2004-W1", "2004W1") + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + get_date_resolution(testtuple) + + def test_get_date_resolution_badweekday(self): + testtuples = ("2004-W53-67", "2004W5367") + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + get_date_resolution(testtuple) + + def test_get_date_resolution_badstr(self): + testtuples = ( + "W53", + "2004-W", + "2014-01-230", + "2014-012-23", + "201-01-23", + "201401230", + "201401", + "", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + get_date_resolution(testtuple) + + +class TestDateParserFunctions(unittest.TestCase): + def test_parse_date(self): + testtuples = ( + ( + "2013", + { + "YYYY": "2013", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + ), + ( + "0001", + { + "YYYY": "0001", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + ), + ( + "19", + { + "YYYY": "19", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + ), + ( + "1981-04-05", + { + "YYYY": "1981", + "MM": "04", + "DD": "05", + "Www": None, + "D": None, + "DDD": None, + }, + ), + ( + "19810405", + { + "YYYY": "1981", + "MM": "04", + "DD": "05", + "Www": None, + "D": None, + "DDD": None, + }, + ), + ( + "1981-04", + { + "YYYY": "1981", + "MM": "04", + "DD": None, + "Www": None, + "D": None, + "DDD": None, + }, + ), + ( + "2004-W53", + { + "YYYY": "2004", + "MM": None, + "DD": None, + "Www": "53", + "D": None, + "DDD": None, + }, + ), + ( + "2009-W01", + { + "YYYY": "2009", + "MM": None, + "DD": None, + "Www": "01", + "D": None, + "DDD": None, + }, + ), + ( + "2004-W53-6", + { + "YYYY": "2004", + "MM": None, + "DD": None, + "Www": "53", + "D": "6", + "DDD": None, + }, + ), + ( + "2004W53", + { + "YYYY": "2004", + "MM": None, + "DD": None, + "Www": "53", + "D": None, + "DDD": None, + }, + ), + ( + "2004W536", + { + "YYYY": "2004", + "MM": None, + "DD": None, + "Www": "53", + "D": "6", + "DDD": None, + }, + ), + ( + "1981-095", + { + "YYYY": "1981", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": "095", + }, + ), + ( + "1981095", + { + "YYYY": "1981", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": "095", + }, + ), + ( + "1980366", + { + "YYYY": "1980", + "MM": None, + "DD": None, + "Www": None, + "D": None, + "DDD": "366", + }, + ), + ) + + for testtuple in testtuples: + with mock.patch.object( + aniso8601.date.PythonTimeBuilder, "build_date" + ) as mockBuildDate: + mockBuildDate.return_value = testtuple[1] + + result = parse_date(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + mockBuildDate.assert_called_once_with(**testtuple[1]) + + def test_parse_date_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + parse_date(testtuple, builder=None) + + def test_parse_date_badstr(self): + testtuples = ( + "W53", + "2004-W", + "2014-01-230", + "2014-012-23", + "201-01-23", + "201401230", + "201401", + "9999 W53", + "20.50230", + "198104", + "bad", + "", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_date(testtuple, builder=None) + + def test_parse_date_mockbuilder(self): + mockBuilder = mock.Mock() + + expectedargs = { + "YYYY": "1981", + "MM": "04", + "DD": "05", + "Www": None, + "D": None, + "DDD": None, + } + + mockBuilder.build_date.return_value = expectedargs + + result = parse_date("1981-04-05", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_date.assert_called_once_with(**expectedargs) diff --git a/libs/aniso8601/tests/test_decimalfraction.py b/libs/aniso8601/tests/test_decimalfraction.py new file mode 100644 index 000000000..dc52f2406 --- /dev/null +++ b/libs/aniso8601/tests/test_decimalfraction.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +from aniso8601.decimalfraction import normalize + + +class TestDecimalFractionFunctions(unittest.TestCase): + def test_normalize(self): + self.assertEqual(normalize(""), "") + self.assertEqual(normalize("12.34"), "12.34") + self.assertEqual(normalize("123,45"), "123.45") + self.assertEqual(normalize("123,45,67"), "123.45.67") diff --git a/libs/aniso8601/tests/test_duration.py b/libs/aniso8601/tests/test_duration.py new file mode 100644 index 000000000..0d7d40a4d --- /dev/null +++ b/libs/aniso8601/tests/test_duration.py @@ -0,0 +1,1402 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +import aniso8601 +from aniso8601.duration import ( + _has_any_component, + _parse_duration_combined, + _parse_duration_prescribed, + _parse_duration_prescribed_notime, + _parse_duration_prescribed_time, + get_duration_resolution, + parse_duration, +) +from aniso8601.exceptions import ISOFormatError +from aniso8601.resolution import DurationResolution +from aniso8601.tests.compat import mock + + +class TestDurationParserFunctions(unittest.TestCase): + def test_get_duration_resolution_years(self): + self.assertEqual(get_duration_resolution("P1Y"), DurationResolution.Years) + self.assertEqual(get_duration_resolution("P1,5Y"), DurationResolution.Years) + self.assertEqual(get_duration_resolution("P1.5Y"), DurationResolution.Years) + + def test_get_duration_resolution_months(self): + self.assertEqual(get_duration_resolution("P1Y2M"), DurationResolution.Months) + self.assertEqual(get_duration_resolution("P1M"), DurationResolution.Months) + self.assertEqual(get_duration_resolution("P1,5M"), DurationResolution.Months) + self.assertEqual(get_duration_resolution("P1.5M"), DurationResolution.Months) + + def test_get_duration_resolution_weeks(self): + self.assertEqual(get_duration_resolution("P1W"), DurationResolution.Weeks) + self.assertEqual(get_duration_resolution("P1,5W"), DurationResolution.Weeks) + self.assertEqual(get_duration_resolution("P1.5W"), DurationResolution.Weeks) + + def test_get_duration_resolution_days(self): + self.assertEqual(get_duration_resolution("P1Y2M3D"), DurationResolution.Days) + self.assertEqual(get_duration_resolution("P1Y2M3,5D"), DurationResolution.Days) + self.assertEqual(get_duration_resolution("P1Y2M3.5D"), DurationResolution.Days) + self.assertEqual(get_duration_resolution("P1D"), DurationResolution.Days) + self.assertEqual(get_duration_resolution("P1,5D"), DurationResolution.Days) + self.assertEqual(get_duration_resolution("P1.5D"), DurationResolution.Days) + + def test_get_duration_resolution_hours(self): + self.assertEqual( + get_duration_resolution("P1Y2M3DT4H"), DurationResolution.Hours + ) + self.assertEqual(get_duration_resolution("PT4H"), DurationResolution.Hours) + + def test_get_duration_resolution_minutes(self): + self.assertEqual( + get_duration_resolution("P1Y2M3DT4H5M"), DurationResolution.Minutes + ) + self.assertEqual(get_duration_resolution("PT4H5M"), DurationResolution.Minutes) + + def test_get_duration_resolution_seconds(self): + self.assertEqual( + get_duration_resolution("P1Y2M3DT4H54M6S"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("P1Y2M3DT4H54M6,5S"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("P1Y2M3DT4H54M6.5S"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("PT4H54M6,5S"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("PT4H54M6.5S"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("PT0.0000001S"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("PT2.0000048S"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("P0003-06-04T12:30:05"), DurationResolution.Seconds + ) + self.assertEqual( + get_duration_resolution("P0003-06-04T12:30:05.5"), + DurationResolution.Seconds, + ) + self.assertEqual( + get_duration_resolution("P0001-02-03T14:43:59.9999997"), + DurationResolution.Seconds, + ) + + def test_parse_duration(self): + testtuples = ( + ( + "P1Y2M3DT4H54M6S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1Y2M3DT4H54M6,5S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "P1Y2M3DT4H54M6.5S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "P1YT4H", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "P1YT54M", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "54", + "TnS": None, + }, + ), + ( + "P1YT6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "P1YT4H54M", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": None, + }, + ), + ( + "P1YT4H6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": "6", + }, + ), + ( + "P1YT54M6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1YT4H54M6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P2MT4H", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "P2MT54M", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "54", + "TnS": None, + }, + ), + ( + "P2MT6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "P2MT4H54M", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": None, + }, + ), + ( + "P2MT4H6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": "6", + }, + ), + ( + "P2MT54M6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P2MT4H54M6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P3DT4H", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "P3DT54M", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": "54", + "TnS": None, + }, + ), + ( + "P3DT6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "P3DT4H54M", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": None, + }, + ), + ( + "P3DT4H6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": None, + "TnS": "6", + }, + ), + ( + "P3DT54M6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P3DT4H54M6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1Y2MT4H", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "P1Y2MT54M", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "54", + "TnS": None, + }, + ), + ( + "P1Y2MT6S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "P1Y2MT4H54M", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": None, + }, + ), + ( + "P1Y2MT4H6S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": "6", + }, + ), + ( + "P1Y2MT54M6S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1Y2MT4H54M6S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1Y3DT4H", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "P1Y3DT54M", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": "54", + "TnS": None, + }, + ), + ( + "P1Y3DT6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "P1Y3DT4H54M", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": None, + }, + ), + ( + "P1Y3DT4H6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": None, + "TnS": "6", + }, + ), + ( + "P1Y3DT54M6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1Y3DT4H54M6S", + { + "PnY": "1", + "PnM": None, + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P2M3DT4H", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "P2M3DT54M", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": "54", + "TnS": None, + }, + ), + ( + "P2M3DT6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "P2M3DT4H54M", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": None, + }, + ), + ( + "P2M3DT4H6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": None, + "TnS": "6", + }, + ), + ( + "P2M3DT54M6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": None, + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P2M3DT4H54M6S", + { + "PnY": None, + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "PT4H54M6,5S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "PT4H54M6.5S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "PT4H", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "PT5M", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "5", + "TnS": None, + }, + ), + ( + "PT6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "PT1H2M", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "1", + "TnM": "2", + "TnS": None, + }, + ), + ( + "PT3H4S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "3", + "TnM": None, + "TnS": "4", + }, + ), + ( + "PT5M6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "5", + "TnS": "6", + }, + ), + ( + "PT0.0000001S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": None, + "TnS": "0.0000001", + }, + ), + ( + "PT2.0000048S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": None, + "TnS": "2.0000048", + }, + ), + ("P1Y", {"PnY": "1", "PnM": None, "PnW": None, "PnD": None}), + ("P1,5Y", {"PnY": "1.5", "PnM": None, "PnW": None, "PnD": None}), + ("P1.5Y", {"PnY": "1.5", "PnM": None, "PnW": None, "PnD": None}), + ("P1M", {"PnY": None, "PnM": "1", "PnW": None, "PnD": None}), + ("P1,5M", {"PnY": None, "PnM": "1.5", "PnW": None, "PnD": None}), + ("P1.5M", {"PnY": None, "PnM": "1.5", "PnW": None, "PnD": None}), + ("P1W", {"PnY": None, "PnM": None, "PnW": "1", "PnD": None}), + ("P1,5W", {"PnY": None, "PnM": None, "PnW": "1.5", "PnD": None}), + ("P1.5W", {"PnY": None, "PnM": None, "PnW": "1.5", "PnD": None}), + ("P1D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1"}), + ("P1,5D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1.5"}), + ("P1.5D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1.5"}), + ("P1Y2M3D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3"}), + ("P1Y2M3,5D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3.5"}), + ("P1Y2M3.5D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3.5"}), + ("P1Y2M", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": None}), + ( + "P0003-06-04T12:30:05", + { + "PnY": "0003", + "PnM": "06", + "PnD": "04", + "TnH": "12", + "TnM": "30", + "TnS": "05", + }, + ), + ( + "P0003-06-04T12:30:05.5", + { + "PnY": "0003", + "PnM": "06", + "PnD": "04", + "TnH": "12", + "TnM": "30", + "TnS": "05.5", + }, + ), + ( + "P0001-02-03T14:43:59.9999997", + { + "PnY": "0001", + "PnM": "02", + "PnD": "03", + "TnH": "14", + "TnM": "43", + "TnS": "59.9999997", + }, + ), + ) + + for testtuple in testtuples: + with mock.patch.object( + aniso8601.duration.PythonTimeBuilder, "build_duration" + ) as mockBuildDuration: + mockBuildDuration.return_value = testtuple[1] + + result = parse_duration(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + mockBuildDuration.assert_called_once_with(**testtuple[1]) + + def test_parse_duration_mockbuilder(self): + mockBuilder = mock.Mock() + + expectedargs = { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + } + + mockBuilder.build_duration.return_value = expectedargs + + result = parse_duration("P1Y2M3DT4H54M6S", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_duration.assert_called_once_with(**expectedargs) + + def test_parse_duration_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + parse_duration(testtuple, builder=None) + + def test_parse_duration_nop(self): + with self.assertRaises(ISOFormatError): + # Duration must start with a P + parse_duration("1Y2M3DT4H54M6S", builder=None) + + def test_parse_duration_weekcombination(self): + # Week designator cannot be combined with other time designators + # https://bitbucket.org/nielsenb/aniso8601/issues/2/week-designators-should-not-be-combinable + + with self.assertRaises(ISOFormatError): + parse_duration("P1Y2W", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1M2W", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P2W3D", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1Y2W3D", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1M2W3D", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1Y1M2W3D", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P7WT4H", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P7WT54M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P7WT6S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P7WT4H54M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P7WT4H6S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P7WT54M6S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P7WT4H54M6S", builder=None) + + def test_parse_duration_negative(self): + with self.assertRaises(ISOFormatError): + parse_duration("P-1Y", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P-2M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P-3D", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P-T4H", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P-T54M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P-T6S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P-7W", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P-1Y2M3DT4H54M6S", builder=None) + + def test_parse_duration_outoforder(self): + # Ensure durations are required to be in the correct order + # https://bitbucket.org/nielsenb/aniso8601/issues/7/durations-with-time-components-before-t + # https://bitbucket.org/nielsenb/aniso8601/issues/8/durations-with-components-in-wrong-order + with self.assertRaises(ISOFormatError): + parse_duration("P1S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1D1S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1H1M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("1Y2M3D1SPT1M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1Y2M3D2MT1S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P2M3D1ST1Y1M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1Y2M2MT3D1S", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("P1D1Y1M", builder=None) + + with self.assertRaises(ISOFormatError): + parse_duration("PT1S1H", builder=None) + + def test_parse_duration_badstr(self): + testtuples = ( + "PPPPPPPPPPPPPPPPPPPPPPPPPPPP", + "PTT", + "PX7DDDTX8888UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU" + "UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU8888888888888888H$H", + "P1Y2M3X.4D", + "P1Y2M3.4XD", + "P1Y2M3DT4H5M6XS", + "PT4H5M6X.2S", + "bad", + "", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_duration(testtuple, builder=None) + + def test_parse_duration_prescribed(self): + testtuples = ( + ( + "P1Y2M3DT4H54M6S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1Y2M3DT4H54M6,5S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "P1Y2M3DT4H54M6.5S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "PT4H54M6,5S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "PT4H54M6.5S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ("P1Y2M3D", {"PnY": "1", "PnM": "2", "" "PnW": None, "PnD": "3"}), + ("P1Y2M3,5D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3.5"}), + ("P1Y2M3.5D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3.5"}), + ("P1Y2M", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": None}), + ("P1Y", {"PnY": "1", "PnM": None, "PnW": None, "PnD": None}), + ("P1,5Y", {"PnY": "1.5", "PnM": None, "PnW": None, "PnD": None}), + ("P1.5Y", {"PnY": "1.5", "PnM": None, "PnW": None, "PnD": None}), + ("P1M", {"PnY": None, "PnM": "1", "PnW": None, "PnD": None}), + ("P1,5M", {"PnY": None, "PnM": "1.5", "PnW": None, "PnD": None}), + ("P1.5M", {"PnY": None, "PnM": "1.5", "PnW": None, "PnD": None}), + ("P1W", {"PnY": None, "PnM": None, "PnW": "1", "PnD": None}), + ("P1,5W", {"PnY": None, "PnM": None, "PnW": "1.5", "PnD": None}), + ("P1.5W", {"PnY": None, "PnM": None, "PnW": "1.5", "PnD": None}), + ("P1D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1"}), + ("P1,5D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1.5"}), + ("P1.5D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1.5"}), + ) + + for testtuple in testtuples: + result = _parse_duration_prescribed(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + + def test_parse_duration_prescribed_negative(self): + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-1Y") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-2M") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-3D") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-4W") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-1Y2M3D") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-T1H") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-T2M") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-T3S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P-1Y2M3DT4H54M6S") + + def test_parse_duration_prescribed_multiplefractions(self): + with self.assertRaises(ISOFormatError): + # Multiple fractions are not allowed + _parse_duration_prescribed("P1Y2M3DT4H5.1234M6.1234S") + + def test_parse_duration_prescribed_middlefraction(self): + with self.assertRaises(ISOFormatError): + # Fraction only allowed on final component + _parse_duration_prescribed("P1Y2M3DT4H5.1234M6S") + + def test_parse_duration_prescribed_suffixgarbage(self): + # Don't allow garbage after the duration + # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed("P1Dasdfasdf") + + def test_parse_duration_prescribed_notime(self): + testtuples = ( + ("P1Y2M3D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3"}), + ("P1Y2M3,5D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3.5"}), + ("P1Y2M3.5D", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": "3.5"}), + ("P1Y3D", {"PnY": "1", "PnM": None, "PnW": None, "PnD": "3"}), + ("P1Y2M", {"PnY": "1", "PnM": "2", "PnW": None, "PnD": None}), + ("P2M3D", {"PnY": None, "PnM": "2", "PnW": None, "PnD": "3"}), + ("P1Y", {"PnY": "1", "PnM": None, "PnW": None, "PnD": None}), + ("P1,5Y", {"PnY": "1.5", "PnM": None, "PnW": None, "PnD": None}), + ("P1.5Y", {"PnY": "1.5", "PnM": None, "PnW": None, "PnD": None}), + ("P1M", {"PnY": None, "PnM": "1", "PnW": None, "PnD": None}), + ("P1,5M", {"PnY": None, "PnM": "1.5", "PnW": None, "PnD": None}), + ("P1.5M", {"PnY": None, "PnM": "1.5", "PnW": None, "PnD": None}), + ("P1W", {"PnY": None, "PnM": None, "PnW": "1", "PnD": None}), + ("P1,5W", {"PnY": None, "PnM": None, "PnW": "1.5", "PnD": None}), + ("P1.5W", {"PnY": None, "PnM": None, "PnW": "1.5", "PnD": None}), + ("P1D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1"}), + ("P1,5D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1.5"}), + ("P1.5D", {"PnY": None, "PnM": None, "PnW": None, "PnD": "1.5"}), + ) + + for testtuple in testtuples: + result = _parse_duration_prescribed_notime(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + + def test_parse_duration_prescribed_notime_timepart(self): + # Ensure no time part is allowed + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1D1S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1H1M") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1Y2M3D4H") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1Y2M3D4H5S") + + def test_parse_duration_prescribed_notime_outoforder(self): + # Ensure durations are required to be in the correct order + # https://bitbucket.org/nielsenb/aniso8601/issues/8/durations-with-components-in-wrong-order + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1H1M") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1D1Y1M") + + def test_parse_duration_prescribed_notime_badstr(self): + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_notime("P1D1S") + + def test_parse_duration_prescribed_time(self): + testtuples = ( + ( + "P1Y2M3DT4H54M6S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6", + }, + ), + ( + "P1Y2M3DT4H54M6,5S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "P1Y2M3DT4H54M6.5S", + { + "PnY": "1", + "PnM": "2", + "PnW": None, + "PnD": "3", + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "PT4H54M6,5S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "PT4H54M6.5S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": "54", + "TnS": "6.5", + }, + ), + ( + "PT4H", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "4", + "TnM": None, + "TnS": None, + }, + ), + ( + "PT5M", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "5", + "TnS": None, + }, + ), + ( + "PT6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": None, + "TnS": "6", + }, + ), + ( + "PT1H2M", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "1", + "TnM": "2", + "TnS": None, + }, + ), + ( + "PT3H4S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": "3", + "TnM": None, + "TnS": "4", + }, + ), + ( + "PT5M6S", + { + "PnY": None, + "PnM": None, + "PnW": None, + "PnD": None, + "TnH": None, + "TnM": "5", + "TnS": "6", + }, + ), + ) + + for testtuple in testtuples: + result = _parse_duration_prescribed_time(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + + def test_parse_duration_prescribed_time_timeindate(self): + # Don't allow time components in date half + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P1Y2M3D4HT54M6S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P1Y2M3D6ST4H54M") + + def test_parse_duration_prescribed_time_dateintime(self): + # Don't allow date components in time half + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P2M3DT1Y4H54M6S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P1Y2MT3D4H54M6S") + + def test_parse_duration_prescribed_time_outoforder(self): + # Ensure durations are required to be in the correct order + # https://bitbucket.org/nielsenb/aniso8601/issues/7/durations-with-time-components-before-t + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("1Y2M3D1SPT1M") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P1Y2M3D2MT1S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P2M3D1ST1Y1M") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P1Y2M2MT3D1S") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("PT1S1H") + + def test_parse_duration_prescribed_time_badstr(self): + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P1Y") + + with self.assertRaises(ISOFormatError): + _parse_duration_prescribed_time("P1Y1M") + + def test_parse_duration_combined(self): + testtuples = ( + ( + "P0003-06-04T12:30:05", + { + "PnY": "0003", + "PnM": "06", + "PnD": "04", + "TnH": "12", + "TnM": "30", + "TnS": "05", + }, + ), + ( + "P0003-06-04T12:30:05,5", + { + "PnY": "0003", + "PnM": "06", + "PnD": "04", + "TnH": "12", + "TnM": "30", + "TnS": "05.5", + }, + ), + ( + "P0003-06-04T12:30:05.5", + { + "PnY": "0003", + "PnM": "06", + "PnD": "04", + "TnH": "12", + "TnM": "30", + "TnS": "05.5", + }, + ), + ( + "P0001-02-03T14:43:59.9999997", + { + "PnY": "0001", + "PnM": "02", + "PnD": "03", + "TnH": "14", + "TnM": "43", + "TnS": "59.9999997", + }, + ), + ) + + for testtuple in testtuples: + result = _parse_duration_combined(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + + def test_parse_duration_combined_suffixgarbage(self): + # Don't allow garbage after the duration + # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed + with self.assertRaises(ISOFormatError): + _parse_duration_combined("P0003-06-04T12:30:05.5asdfasdf") + + def test_has_any_component(self): + self.assertTrue(_has_any_component("P1Y", ["Y", "M"])) + self.assertFalse(_has_any_component("P1Y", ["M", "D"])) diff --git a/libs/aniso8601/tests/test_init.py b/libs/aniso8601/tests/test_init.py new file mode 100644 index 000000000..d5604c6b9 --- /dev/null +++ b/libs/aniso8601/tests/test_init.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +import aniso8601 + + +class TestInitFunctions(unittest.TestCase): + def test_import(self): + # Verify the function mappings + self.assertEqual(aniso8601.parse_datetime, aniso8601.time.parse_datetime) + self.assertEqual(aniso8601.parse_time, aniso8601.time.parse_time) + self.assertEqual( + aniso8601.get_time_resolution, aniso8601.time.get_time_resolution + ) + self.assertEqual( + aniso8601.get_datetime_resolution, aniso8601.time.get_datetime_resolution + ) + + self.assertEqual(aniso8601.parse_date, aniso8601.date.parse_date) + self.assertEqual( + aniso8601.get_date_resolution, aniso8601.date.get_date_resolution + ) + + self.assertEqual(aniso8601.parse_duration, aniso8601.duration.parse_duration) + self.assertEqual( + aniso8601.get_duration_resolution, + aniso8601.duration.get_duration_resolution, + ) + + self.assertEqual(aniso8601.parse_interval, aniso8601.interval.parse_interval) + self.assertEqual( + aniso8601.parse_repeating_interval, + aniso8601.interval.parse_repeating_interval, + ) + self.assertEqual( + aniso8601.get_interval_resolution, + aniso8601.interval.get_interval_resolution, + ) + self.assertEqual( + aniso8601.get_repeating_interval_resolution, + aniso8601.interval.get_repeating_interval_resolution, + ) diff --git a/libs/aniso8601/tests/test_interval.py b/libs/aniso8601/tests/test_interval.py new file mode 100644 index 000000000..f01d15112 --- /dev/null +++ b/libs/aniso8601/tests/test_interval.py @@ -0,0 +1,1675 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +import aniso8601 +from aniso8601.builders import ( + DatetimeTuple, + DateTuple, + DurationTuple, + IntervalTuple, + TimeTuple, + TimezoneTuple, +) +from aniso8601.exceptions import ISOFormatError +from aniso8601.interval import ( + _get_interval_component_resolution, + _get_interval_resolution, + _parse_interval, + _parse_interval_end, + get_interval_resolution, + get_repeating_interval_resolution, + parse_interval, + parse_repeating_interval, +) +from aniso8601.resolution import IntervalResolution +from aniso8601.tests.compat import mock + + +class TestIntervalParser_UtilityFunctions(unittest.TestCase): + def test_get_interval_resolution(self): + self.assertEqual( + _get_interval_resolution( + IntervalTuple( + start=DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + end=DatetimeTuple( + DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + TimeTuple(hh="04", mm="05", ss="06", tz=None), + ), + duration=None, + ) + ), + IntervalResolution.Seconds, + ) + self.assertEqual( + _get_interval_resolution( + IntervalTuple( + start=DatetimeTuple( + DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + TimeTuple(hh="04", mm="05", ss="06", tz=None), + ), + end=DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + duration=None, + ) + ), + IntervalResolution.Seconds, + ) + + self.assertEqual( + _get_interval_resolution( + IntervalTuple( + start=DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + end=None, + duration=DurationTuple( + PnY="1", PnM="2", PnW=None, PnD="3", TnH="4", TnM="5", TnS="6" + ), + ) + ), + IntervalResolution.Seconds, + ) + self.assertEqual( + _get_interval_resolution( + IntervalTuple( + start=DatetimeTuple( + DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + TimeTuple(hh="04", mm="05", ss="06", tz=None), + ), + end=None, + duration=DurationTuple( + PnY="1", + PnM="2", + PnW=None, + PnD="3", + TnH=None, + TnM=None, + TnS=None, + ), + ) + ), + IntervalResolution.Seconds, + ) + + self.assertEqual( + _get_interval_resolution( + IntervalTuple( + start=None, + end=DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + duration=DurationTuple( + PnY="1", PnM="2", PnW=None, PnD="3", TnH="4", TnM="5", TnS="6" + ), + ) + ), + IntervalResolution.Seconds, + ) + self.assertEqual( + _get_interval_resolution( + IntervalTuple( + start=None, + end=DatetimeTuple( + DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + TimeTuple(hh="04", mm="05", ss="06", tz=None), + ), + duration=DurationTuple( + PnY="1", + PnM="2", + PnW=None, + PnD="3", + TnH=None, + TnM=None, + TnS=None, + ), + ) + ), + IntervalResolution.Seconds, + ) + + def test_get_interval_component_resolution(self): + self.assertEqual( + _get_interval_component_resolution( + DateTuple(YYYY="2001", MM=None, DD=None, Www=None, D=None, DDD="123") + ), + IntervalResolution.Ordinal, + ) + self.assertEqual( + _get_interval_component_resolution( + DateTuple(YYYY="2001", MM=None, DD=None, Www="12", D="3", DDD=None) + ), + IntervalResolution.Weekday, + ) + self.assertEqual( + _get_interval_component_resolution( + DateTuple(YYYY="2001", MM=None, DD=None, Www="12", D=None, DDD=None) + ), + IntervalResolution.Week, + ) + self.assertEqual( + _get_interval_component_resolution( + DateTuple(YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None) + ), + IntervalResolution.Day, + ) + self.assertEqual( + _get_interval_component_resolution( + DateTuple(YYYY="2001", MM="02", DD=None, Www=None, D=None, DDD=None) + ), + IntervalResolution.Month, + ) + self.assertEqual( + _get_interval_component_resolution( + DateTuple(YYYY="2001", MM=None, DD=None, Www=None, D=None, DDD=None) + ), + IntervalResolution.Year, + ) + + self.assertEqual( + _get_interval_component_resolution( + DatetimeTuple( + DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + TimeTuple(hh="04", mm="05", ss="06", tz=None), + ) + ), + IntervalResolution.Seconds, + ) + self.assertEqual( + _get_interval_component_resolution( + DatetimeTuple( + DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + TimeTuple(hh="04", mm="05", ss=None, tz=None), + ) + ), + IntervalResolution.Minutes, + ) + self.assertEqual( + _get_interval_component_resolution( + DatetimeTuple( + DateTuple( + YYYY="2001", MM="02", DD="03", Www=None, D=None, DDD=None + ), + TimeTuple(hh="04", mm=None, ss=None, tz=None), + ) + ), + IntervalResolution.Hours, + ) + + self.assertEqual( + _get_interval_component_resolution( + DurationTuple( + PnY="1", PnM="2", PnW=None, PnD="3", TnH="4", TnM="5", TnS="6" + ) + ), + IntervalResolution.Seconds, + ) + self.assertEqual( + _get_interval_component_resolution( + DurationTuple( + PnY="1", PnM="2", PnW=None, PnD="3", TnH="4", TnM="5", TnS=None + ) + ), + IntervalResolution.Minutes, + ) + self.assertEqual( + _get_interval_component_resolution( + DurationTuple( + PnY="1", PnM="2", PnW=None, PnD="3", TnH="4", TnM=None, TnS=None + ) + ), + IntervalResolution.Hours, + ) + self.assertEqual( + _get_interval_component_resolution( + DurationTuple( + PnY="1", PnM="2", PnW=None, PnD="3", TnH=None, TnM=None, TnS=None + ) + ), + IntervalResolution.Day, + ) + self.assertEqual( + _get_interval_component_resolution( + DurationTuple( + PnY="1", PnM="2", PnW=None, PnD=None, TnH=None, TnM=None, TnS=None + ) + ), + IntervalResolution.Month, + ) + self.assertEqual( + _get_interval_component_resolution( + DurationTuple( + PnY="1", PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None + ) + ), + IntervalResolution.Year, + ) + self.assertEqual( + _get_interval_component_resolution( + DurationTuple( + PnY=None, PnM=None, PnW="3", PnD=None, TnH=None, TnM=None, TnS=None + ) + ), + IntervalResolution.Week, + ) + + +class TestIntervalParserFunctions(unittest.TestCase): + def test_get_interval_resolution_date(self): + self.assertEqual(get_interval_resolution("P1.5Y/2018"), IntervalResolution.Year) + self.assertEqual( + get_interval_resolution("P1.5Y/2018-03"), IntervalResolution.Month + ) + self.assertEqual( + get_interval_resolution("P1.5Y/2018-03-06"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("P1.5Y/2018W01"), IntervalResolution.Week + ) + self.assertEqual( + get_interval_resolution("P1.5Y/2018-306"), IntervalResolution.Ordinal + ) + self.assertEqual( + get_interval_resolution("P1.5Y/2018W012"), IntervalResolution.Weekday + ) + + self.assertEqual(get_interval_resolution("2018/P1.5Y"), IntervalResolution.Year) + self.assertEqual( + get_interval_resolution("2018-03/P1.5Y"), IntervalResolution.Month + ) + self.assertEqual( + get_interval_resolution("2018-03-06/P1.5Y"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("2018W01/P1.5Y"), IntervalResolution.Week + ) + self.assertEqual( + get_interval_resolution("2018-306/P1.5Y"), IntervalResolution.Ordinal + ) + self.assertEqual( + get_interval_resolution("2018W012/P1.5Y"), IntervalResolution.Weekday + ) + + def test_get_interval_resolution_time(self): + self.assertEqual( + get_interval_resolution("P1M/1981-04-05T01"), IntervalResolution.Hours + ) + self.assertEqual( + get_interval_resolution("P1M/1981-04-05T01:01"), IntervalResolution.Minutes + ) + self.assertEqual( + get_interval_resolution("P1M/1981-04-05T01:01:00"), + IntervalResolution.Seconds, + ) + + self.assertEqual( + get_interval_resolution("1981-04-05T01/P1M"), IntervalResolution.Hours + ) + self.assertEqual( + get_interval_resolution("1981-04-05T01:01/P1M"), IntervalResolution.Minutes + ) + self.assertEqual( + get_interval_resolution("1981-04-05T01:01:00/P1M"), + IntervalResolution.Seconds, + ) + + def test_get_interval_resolution_duration(self): + self.assertEqual( + get_interval_resolution("2014-11-12/P1Y2M3D"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("2014-11-12/P1Y2M"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("2014-11-12/P1Y"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("2014-11-12/P1W"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("2014-11-12/P1Y2M3DT4H"), IntervalResolution.Hours + ) + self.assertEqual( + get_interval_resolution("2014-11-12/P1Y2M3DT4H54M"), + IntervalResolution.Minutes, + ) + self.assertEqual( + get_interval_resolution("2014-11-12/P1Y2M3DT4H54M6S"), + IntervalResolution.Seconds, + ) + + self.assertEqual( + get_interval_resolution("P1Y2M3D/2014-11-12"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("P1Y2M/2014-11-12"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("P1Y/2014-11-12"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("P1W/2014-11-12"), IntervalResolution.Day + ) + self.assertEqual( + get_interval_resolution("P1Y2M3DT4H/2014-11-12"), IntervalResolution.Hours + ) + self.assertEqual( + get_interval_resolution("P1Y2M3DT4H54M/2014-11-12"), + IntervalResolution.Minutes, + ) + self.assertEqual( + get_interval_resolution("P1Y2M3DT4H54M6S/2014-11-12"), + IntervalResolution.Seconds, + ) + + def test_parse_interval(self): + testtuples = ( + ( + "P1M/1981-04-05T01:01:00", + { + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "duration": DurationTuple(None, "1", None, None, None, None, None), + }, + ), + ( + "P1M/1981-04-05", + { + "end": DateTuple("1981", "04", "05", None, None, None), + "duration": DurationTuple(None, "1", None, None, None, None, None), + }, + ), + ( + "P1,5Y/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + "1.5", None, None, None, None, None, None + ), + }, + ), + ( + "P1.5Y/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + "1.5", None, None, None, None, None, None + ), + }, + ), + ( + "PT1H/2014-11-12", + { + "end": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "1", None, None), + }, + ), + ( + "PT4H54M6.5S/2014-11-12", + { + "end": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "4", "54", "6.5"), + }, + ), + ( + "PT10H/2050-03-01T13:00:00Z", + { + "end": DatetimeTuple( + DateTuple("2050", "03", "01", None, None, None), + TimeTuple( + "13", + "00", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "duration": DurationTuple(None, None, None, None, "10", None, None), + }, + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + "PT0.0000001S/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "0.0000001" + ), + }, + ), + ( + "PT2.0000048S/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "2.0000048" + ), + }, + ), + ( + "1981-04-05T01:01:00/P1M1DT1M", + { + "start": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "duration": DurationTuple(None, "1", None, "1", None, "1", None), + }, + ), + ( + "1981-04-05/P1M1D", + { + "start": DateTuple("1981", "04", "05", None, None, None), + "duration": DurationTuple(None, "1", None, "1", None, None, None), + }, + ), + ( + "2018-03-06/P2,5M", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, "2.5", None, None, None, None, None + ), + }, + ), + ( + "2018-03-06/P2.5M", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, "2.5", None, None, None, None, None + ), + }, + ), + ( + "2014-11-12/PT1H", + { + "start": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "1", None, None), + }, + ), + ( + "2014-11-12/PT4H54M6.5S", + { + "start": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "4", "54", "6.5"), + }, + ), + ( + "2050-03-01T13:00:00Z/PT10H", + { + "start": DatetimeTuple( + DateTuple("2050", "03", "01", None, None, None), + TimeTuple( + "13", + "00", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "duration": DurationTuple(None, None, None, None, "10", None, None), + }, + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + "2018-03-06/PT0.0000001S", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "0.0000001" + ), + }, + ), + ( + "2018-03-06/PT2.0000048S", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "2.0000048" + ), + }, + ), + ( + "1980-03-05T01:01:00/1981-04-05T01:01:00", + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + }, + ), + ( + "1980-03-05T01:01:00/1981-04-05", + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DateTuple("1981", "04", "05", None, None, None), + }, + ), + ( + "1980-03-05/1981-04-05T01:01:00", + { + "start": DateTuple("1980", "03", "05", None, None, None), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + }, + ), + ( + "1980-03-05/1981-04-05", + { + "start": DateTuple("1980", "03", "05", None, None, None), + "end": DateTuple("1981", "04", "05", None, None, None), + }, + ), + ( + "1981-04-05/1980-03-05", + { + "start": DateTuple("1981", "04", "05", None, None, None), + "end": DateTuple("1980", "03", "05", None, None, None), + }, + ), + ( + "2050-03-01T13:00:00Z/2050-05-11T15:30:00Z", + { + "start": DatetimeTuple( + DateTuple("2050", "03", "01", None, None, None), + TimeTuple( + "13", + "00", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "end": DatetimeTuple( + DateTuple("2050", "05", "11", None, None, None), + TimeTuple( + "15", + "30", + "00", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + }, + ), + # Test concise interval + ( + "2020-01-01/02", + { + "start": DateTuple("2020", "01", "01", None, None, None), + "end": DateTuple(None, None, "02", None, None, None), + }, + ), + ( + "2008-02-15/03-14", + { + "start": DateTuple("2008", "02", "15", None, None, None), + "end": DateTuple(None, "03", "14", None, None, None), + }, + ), + ( + "2007-12-14T13:30/15:30", + { + "start": DatetimeTuple( + DateTuple("2007", "12", "14", None, None, None), + TimeTuple("13", "30", None, None), + ), + "end": TimeTuple("15", "30", None, None), + }, + ), + ( + "2007-11-13T09:00/15T17:00", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("09", "00", None, None), + ), + "end": DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + }, + ), + ( + "2007-11-13T00:00/16T00:00", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + "end": DatetimeTuple( + DateTuple(None, None, "16", None, None, None), + TimeTuple("00", "00", None, None), + ), + }, + ), + ( + "2007-11-13T09:00Z/15T17:00", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple( + "09", + "00", + None, + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "end": DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + }, + ), + ( + "2007-11-13T00:00/12:34.567", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + "end": TimeTuple("12", "34.567", None, None), + }, + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + "1980-03-05T01:01:00.0000001/" "1981-04-05T14:43:59.9999997", + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00.0000001", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("14", "43", "59.9999997", None), + ), + }, + ), + ) + + for testtuple in testtuples: + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_interval" + ) as mockBuildInterval: + mockBuildInterval.return_value = testtuple[1] + + result = parse_interval(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + mockBuildInterval.assert_called_once_with(**testtuple[1]) + + # Test different separators + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_interval" + ) as mockBuildInterval: + expectedargs = { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + } + + mockBuildInterval.return_value = expectedargs + + result = parse_interval( + "1980-03-05T01:01:00--1981-04-05T01:01:00", intervaldelimiter="--" + ) + + self.assertEqual(result, expectedargs) + mockBuildInterval.assert_called_once_with(**expectedargs) + + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_interval" + ) as mockBuildInterval: + expectedargs = { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + } + + mockBuildInterval.return_value = expectedargs + + result = parse_interval( + "1980-03-05 01:01:00/1981-04-05 01:01:00", datetimedelimiter=" " + ) + + self.assertEqual(result, expectedargs) + mockBuildInterval.assert_called_once_with(**expectedargs) + + def test_parse_interval_mockbuilder(self): + mockBuilder = mock.Mock() + + expectedargs = { + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "duration": DurationTuple(None, "1", None, None, None, None, None), + } + + mockBuilder.build_interval.return_value = expectedargs + + result = parse_interval("P1M/1981-04-05T01:01:00", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_interval.assert_called_once_with(**expectedargs) + + mockBuilder = mock.Mock() + + expectedargs = { + "start": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "1", None, None), + } + + mockBuilder.build_interval.return_value = expectedargs + + result = parse_interval("2014-11-12/PT1H", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_interval.assert_called_once_with(**expectedargs) + + mockBuilder = mock.Mock() + + expectedargs = { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + } + + mockBuilder.build_interval.return_value = expectedargs + + result = parse_interval( + "1980-03-05T01:01:00/1981-04-05T01:01:00", builder=mockBuilder + ) + + self.assertEqual(result, expectedargs) + mockBuilder.build_interval.assert_called_once_with(**expectedargs) + + def test_parse_interval_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + parse_interval(testtuple, builder=None) + + def test_parse_interval_baddelimiter(self): + testtuples = ( + "1980-03-05T01:01:00,1981-04-05T01:01:00", + "P1M 1981-04-05T01:01:00", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_interval(testtuple, builder=None) + + def test_parse_interval_badstr(self): + testtuples = ("/", "0/0/0", "20.50230/0", "5/%", "1/21", "bad", "") + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_interval(testtuple, builder=None) + + def test_parse_interval_repeating(self): + # Parse interval can't parse repeating intervals + with self.assertRaises(ISOFormatError): + parse_interval("R3/1981-04-05/P1D") + + with self.assertRaises(ISOFormatError): + parse_interval("R3/1981-04-05/P0003-06-04T12:30:05.5") + + with self.assertRaises(ISOFormatError): + parse_interval("R/PT1H2M/1980-03-05T01:01:00") + + def test_parse_interval_suffixgarbage(self): + # Don't allow garbage after the duration + # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed + with self.assertRaises(ISOFormatError): + parse_interval("2001/P1Dasdf", builder=None) + + with self.assertRaises(ISOFormatError): + parse_interval("P1Dasdf/2001", builder=None) + + with self.assertRaises(ISOFormatError): + parse_interval("2001/P0003-06-04T12:30:05.5asdfasdf", builder=None) + + with self.assertRaises(ISOFormatError): + parse_interval("P0003-06-04T12:30:05.5asdfasdf/2001", builder=None) + + def test_parse_interval_internal(self): + # Test the internal _parse_interval function + testtuples = ( + ( + "P1M/1981-04-05T01:01:00", + { + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "duration": DurationTuple(None, "1", None, None, None, None, None), + }, + ), + ( + "P1M/1981-04-05", + { + "end": DateTuple("1981", "04", "05", None, None, None), + "duration": DurationTuple(None, "1", None, None, None, None, None), + }, + ), + ( + "P1,5Y/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + "1.5", None, None, None, None, None, None + ), + }, + ), + ( + "P1.5Y/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + "1.5", None, None, None, None, None, None + ), + }, + ), + ( + "PT1H/2014-11-12", + { + "end": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "1", None, None), + }, + ), + ( + "PT4H54M6.5S/2014-11-12", + { + "end": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "4", "54", "6.5"), + }, + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + "PT0.0000001S/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "0.0000001" + ), + }, + ), + ( + "PT2.0000048S/2018-03-06", + { + "end": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "2.0000048" + ), + }, + ), + ( + "1981-04-05T01:01:00/P1M1DT1M", + { + "start": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "duration": DurationTuple(None, "1", None, "1", None, "1", None), + }, + ), + ( + "1981-04-05/P1M1D", + { + "start": DateTuple("1981", "04", "05", None, None, None), + "duration": DurationTuple(None, "1", None, "1", None, None, None), + }, + ), + ( + "2018-03-06/P2,5M", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, "2.5", None, None, None, None, None + ), + }, + ), + ( + "2018-03-06/P2.5M", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, "2.5", None, None, None, None, None + ), + }, + ), + ( + "2014-11-12/PT1H", + { + "start": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "1", None, None), + }, + ), + ( + "2014-11-12/PT4H54M6.5S", + { + "start": DateTuple("2014", "11", "12", None, None, None), + "duration": DurationTuple(None, None, None, None, "4", "54", "6.5"), + }, + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + "2018-03-06/PT0.0000001S", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "0.0000001" + ), + }, + ), + ( + "2018-03-06/PT2.0000048S", + { + "start": DateTuple("2018", "03", "06", None, None, None), + "duration": DurationTuple( + None, None, None, None, None, None, "2.0000048" + ), + }, + ), + ( + "1980-03-05T01:01:00/1981-04-05T01:01:00", + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + }, + ), + ( + "1980-03-05T01:01:00/1981-04-05", + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DateTuple("1981", "04", "05", None, None, None), + }, + ), + ( + "1980-03-05/1981-04-05T01:01:00", + { + "start": DateTuple("1980", "03", "05", None, None, None), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + }, + ), + ( + "1980-03-05/1981-04-05", + { + "start": DateTuple("1980", "03", "05", None, None, None), + "end": DateTuple("1981", "04", "05", None, None, None), + }, + ), + ( + "1981-04-05/1980-03-05", + { + "start": DateTuple("1981", "04", "05", None, None, None), + "end": DateTuple("1980", "03", "05", None, None, None), + }, + ), + # Test concise interval + ( + "2020-01-01/02", + { + "start": DateTuple("2020", "01", "01", None, None, None), + "end": DateTuple(None, None, "02", None, None, None), + }, + ), + ( + "2008-02-15/03-14", + { + "start": DateTuple("2008", "02", "15", None, None, None), + "end": DateTuple(None, "03", "14", None, None, None), + }, + ), + ( + "2007-12-14T13:30/15:30", + { + "start": DatetimeTuple( + DateTuple("2007", "12", "14", None, None, None), + TimeTuple("13", "30", None, None), + ), + "end": TimeTuple("15", "30", None, None), + }, + ), + ( + "2007-11-13T09:00/15T17:00", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("09", "00", None, None), + ), + "end": DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + }, + ), + ( + "2007-11-13T00:00/16T00:00", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + "end": DatetimeTuple( + DateTuple(None, None, "16", None, None, None), + TimeTuple("00", "00", None, None), + ), + }, + ), + ( + "2007-11-13T09:00Z/15T17:00", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple( + "09", + "00", + None, + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + "end": DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + }, + ), + ( + "2007-11-13T00:00/12:34.567", + { + "start": DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + "end": TimeTuple("12", "34.567", None, None), + }, + ), + # Make sure we truncate, not round + # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is + ( + "1980-03-05T01:01:00.0000001/" "1981-04-05T14:43:59.9999997", + { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00.0000001", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("14", "43", "59.9999997", None), + ), + }, + ), + ) + + for testtuple in testtuples: + mockBuilder = mock.Mock() + mockBuilder.build_interval.return_value = testtuple[1] + + result = _parse_interval(testtuple[0], mockBuilder) + + self.assertEqual(result, testtuple[1]) + mockBuilder.build_interval.assert_called_once_with(**testtuple[1]) + + # Test different separators + expectedargs = { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + } + + mockBuilder = mock.Mock() + mockBuilder.build_interval.return_value = expectedargs + + result = _parse_interval( + "1980-03-05T01:01:00--1981-04-05T01:01:00", + mockBuilder, + intervaldelimiter="--", + ) + + self.assertEqual(result, expectedargs) + mockBuilder.build_interval.assert_called_once_with(**expectedargs) + + expectedargs = { + "start": DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + "end": DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + } + + mockBuilder = mock.Mock() + mockBuilder.build_interval.return_value = expectedargs + + _parse_interval( + "1980-03-05 01:01:00/1981-04-05 01:01:00", + mockBuilder, + datetimedelimiter=" ", + ) + + self.assertEqual(result, expectedargs) + mockBuilder.build_interval.assert_called_once_with(**expectedargs) + + def test_parse_interval_end(self): + self.assertEqual( + _parse_interval_end( + "02", DateTuple("2020", "01", "01", None, None, None), "T" + ), + DateTuple(None, None, "02", None, None, None), + ) + + self.assertEqual( + _parse_interval_end( + "03-14", DateTuple("2008", "02", "15", None, None, None), "T" + ), + DateTuple(None, "03", "14", None, None, None), + ) + + self.assertEqual( + _parse_interval_end( + "0314", DateTuple("2008", "02", "15", None, None, None), "T" + ), + DateTuple(None, "03", "14", None, None, None), + ) + + self.assertEqual( + _parse_interval_end( + "15:30", + DatetimeTuple( + DateTuple("2007", "12", "14", None, None, None), + TimeTuple("13", "30", None, None), + ), + "T", + ), + TimeTuple("15", "30", None, None), + ) + + self.assertEqual( + _parse_interval_end( + "15T17:00", + DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("09", "00", None, None), + ), + "T", + ), + DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + ) + + self.assertEqual( + _parse_interval_end( + "16T00:00", + DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + "T", + ), + DatetimeTuple( + DateTuple(None, None, "16", None, None, None), + TimeTuple("00", "00", None, None), + ), + ) + + self.assertEqual( + _parse_interval_end( + "15 17:00", + DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("09", "00", None, None), + ), + " ", + ), + DatetimeTuple( + DateTuple(None, None, "15", None, None, None), + TimeTuple("17", "00", None, None), + ), + ) + + self.assertEqual( + _parse_interval_end( + "12:34.567", + DatetimeTuple( + DateTuple("2007", "11", "13", None, None, None), + TimeTuple("00", "00", None, None), + ), + "T", + ), + TimeTuple("12", "34.567", None, None), + ) + + +class TestRepeatingIntervalParserFunctions(unittest.TestCase): + def test_get_interval_resolution_date(self): + self.assertEqual( + get_repeating_interval_resolution("R/P1.5Y/2018"), IntervalResolution.Year + ) + self.assertEqual( + get_repeating_interval_resolution("R1/P1.5Y/2018-03"), + IntervalResolution.Month, + ) + self.assertEqual( + get_repeating_interval_resolution("R2/P1.5Y/2018-03-06"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R3/P1.5Y/2018W01"), + IntervalResolution.Week, + ) + self.assertEqual( + get_repeating_interval_resolution("R4/P1.5Y/2018-306"), + IntervalResolution.Ordinal, + ) + self.assertEqual( + get_repeating_interval_resolution("R5/P1.5Y/2018W012"), + IntervalResolution.Weekday, + ) + + self.assertEqual( + get_repeating_interval_resolution("R/2018/P1.5Y"), IntervalResolution.Year + ) + self.assertEqual( + get_repeating_interval_resolution("R1/2018-03/P1.5Y"), + IntervalResolution.Month, + ) + self.assertEqual( + get_repeating_interval_resolution("R2/2018-03-06/P1.5Y"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R3/2018W01/P1.5Y"), + IntervalResolution.Week, + ) + self.assertEqual( + get_repeating_interval_resolution("R4/2018-306/P1.5Y"), + IntervalResolution.Ordinal, + ) + self.assertEqual( + get_repeating_interval_resolution("R5/2018W012/P1.5Y"), + IntervalResolution.Weekday, + ) + + def test_get_interval_resolution_time(self): + self.assertEqual( + get_repeating_interval_resolution("R/P1M/1981-04-05T01"), + IntervalResolution.Hours, + ) + self.assertEqual( + get_repeating_interval_resolution("R1/P1M/1981-04-05T01:01"), + IntervalResolution.Minutes, + ) + self.assertEqual( + get_repeating_interval_resolution("R2/P1M/1981-04-05T01:01:00"), + IntervalResolution.Seconds, + ) + + self.assertEqual( + get_repeating_interval_resolution("R/1981-04-05T01/P1M"), + IntervalResolution.Hours, + ) + self.assertEqual( + get_repeating_interval_resolution("R1/1981-04-05T01:01/P1M"), + IntervalResolution.Minutes, + ) + self.assertEqual( + get_repeating_interval_resolution("R2/1981-04-05T01:01:00/P1M"), + IntervalResolution.Seconds, + ) + + def test_get_interval_resolution_duration(self): + self.assertEqual( + get_repeating_interval_resolution("R/2014-11-12/P1Y2M3D"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R1/2014-11-12/P1Y2M"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R2/2014-11-12/P1Y"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R3/2014-11-12/P1W"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R4/2014-11-12/P1Y2M3DT4H"), + IntervalResolution.Hours, + ) + self.assertEqual( + get_repeating_interval_resolution("R5/2014-11-12/P1Y2M3DT4H54M"), + IntervalResolution.Minutes, + ) + self.assertEqual( + get_repeating_interval_resolution("R6/2014-11-12/P1Y2M3DT4H54M6S"), + IntervalResolution.Seconds, + ) + + self.assertEqual( + get_repeating_interval_resolution("R/P1Y2M3D/2014-11-12"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R1/P1Y2M/2014-11-12"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R2/P1Y/2014-11-12"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R3/P1W/2014-11-12"), + IntervalResolution.Day, + ) + self.assertEqual( + get_repeating_interval_resolution("R4/P1Y2M3DT4H/2014-11-12"), + IntervalResolution.Hours, + ) + self.assertEqual( + get_repeating_interval_resolution("R5/P1Y2M3DT4H54M/2014-11-12"), + IntervalResolution.Minutes, + ) + self.assertEqual( + get_repeating_interval_resolution("R6/P1Y2M3DT4H54M6S/2014-11-12"), + IntervalResolution.Seconds, + ) + + def test_parse_repeating_interval(self): + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_repeating_interval" + ) as mockBuilder: + expectedargs = { + "R": False, + "Rnn": "3", + "interval": IntervalTuple( + DateTuple("1981", "04", "05", None, None, None), + None, + DurationTuple(None, None, None, "1", None, None, None), + ), + } + + mockBuilder.return_value = expectedargs + + result = parse_repeating_interval("R3/1981-04-05/P1D") + + self.assertEqual(result, expectedargs) + mockBuilder.assert_called_once_with(**expectedargs) + + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_repeating_interval" + ) as mockBuilder: + expectedargs = { + "R": False, + "Rnn": "11", + "interval": IntervalTuple( + None, + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DurationTuple(None, None, None, None, "1", "2", None), + ), + } + + mockBuilder.return_value = expectedargs + + result = parse_repeating_interval("R11/PT1H2M/1980-03-05T01:01:00") + + self.assertEqual(result, expectedargs) + mockBuilder.assert_called_once_with(**expectedargs) + + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_repeating_interval" + ) as mockBuilder: + expectedargs = { + "R": False, + "Rnn": "2", + "interval": IntervalTuple( + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + None, + ), + } + + mockBuilder.return_value = expectedargs + + result = parse_repeating_interval( + "R2--1980-03-05T01:01:00--" "1981-04-05T01:01:00", + intervaldelimiter="--", + ) + + self.assertEqual(result, expectedargs) + mockBuilder.assert_called_once_with(**expectedargs) + + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_repeating_interval" + ) as mockBuilder: + expectedargs = { + "R": False, + "Rnn": "2", + "interval": IntervalTuple( + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DatetimeTuple( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + None, + ), + } + + mockBuilder.return_value = expectedargs + + result = parse_repeating_interval( + "R2/" "1980-03-05 01:01:00/" "1981-04-05 01:01:00", + datetimedelimiter=" ", + ) + + self.assertEqual(result, expectedargs) + mockBuilder.assert_called_once_with(**expectedargs) + + with mock.patch.object( + aniso8601.interval.PythonTimeBuilder, "build_repeating_interval" + ) as mockBuilder: + expectedargs = { + "R": True, + "Rnn": None, + "interval": IntervalTuple( + None, + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DurationTuple(None, None, None, None, "1", "2", None), + ), + } + + mockBuilder.return_value = expectedargs + + result = parse_repeating_interval("R/PT1H2M/1980-03-05T01:01:00") + + self.assertEqual(result, expectedargs) + mockBuilder.assert_called_once_with(**expectedargs) + + def test_parse_repeating_interval_mockbuilder(self): + mockBuilder = mock.Mock() + + args = { + "R": False, + "Rnn": "3", + "interval": IntervalTuple( + DateTuple("1981", "04", "05", None, None, None), + None, + DurationTuple(None, None, None, "1", None, None, None), + ), + } + + mockBuilder.build_repeating_interval.return_value = args + + result = parse_repeating_interval("R3/1981-04-05/P1D", builder=mockBuilder) + + self.assertEqual(result, args) + mockBuilder.build_repeating_interval.assert_called_once_with(**args) + + mockBuilder = mock.Mock() + + args = { + "R": False, + "Rnn": "11", + "interval": IntervalTuple( + None, + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DurationTuple(None, None, None, None, "1", "2", None), + ), + } + + mockBuilder.build_repeating_interval.return_value = args + + result = parse_repeating_interval( + "R11/PT1H2M/1980-03-05T01:01:00", builder=mockBuilder + ) + + self.assertEqual(result, args) + mockBuilder.build_repeating_interval.assert_called_once_with(**args) + + mockBuilder = mock.Mock() + + args = { + "R": True, + "Rnn": None, + "interval": IntervalTuple( + None, + DatetimeTuple( + DateTuple("1980", "03", "05", None, None, None), + TimeTuple("01", "01", "00", None), + ), + DurationTuple(None, None, None, None, "1", "2", None), + ), + } + + mockBuilder.build_repeating_interval.return_value = args + + result = parse_repeating_interval( + "R/PT1H2M/1980-03-05T01:01:00", builder=mockBuilder + ) + + self.assertEqual(result, args) + mockBuilder.build_repeating_interval.assert_called_once_with(**args) + + def test_parse_repeating_interval_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + parse_repeating_interval(testtuple, builder=None) + + def test_parse_repeating_interval_baddelimiter(self): + testtuples = ("R,PT1H2M,1980-03-05T01:01:00", "R3 1981-04-05 P1D") + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_repeating_interval(testtuple, builder=None) + + def test_parse_repeating_interval_suffixgarbage(self): + # Don't allow garbage after the duration + # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed + with self.assertRaises(ISOFormatError): + parse_repeating_interval("R3/1981-04-05/P1Dasdf", builder=None) + + with self.assertRaises(ISOFormatError): + parse_repeating_interval( + "R3/" "1981-04-05/" "P0003-06-04T12:30:05.5asdfasdf", builder=None + ) + + def test_parse_repeating_interval_badstr(self): + testtuples = ("bad", "") + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_repeating_interval(testtuple, builder=None) diff --git a/libs/aniso8601/tests/test_time.py b/libs/aniso8601/tests/test_time.py new file mode 100644 index 000000000..dcee5e03f --- /dev/null +++ b/libs/aniso8601/tests/test_time.py @@ -0,0 +1,539 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +import aniso8601 +from aniso8601.builders import DatetimeTuple, DateTuple, TimeTuple, TimezoneTuple +from aniso8601.exceptions import ISOFormatError +from aniso8601.resolution import TimeResolution +from aniso8601.tests.compat import mock +from aniso8601.time import ( + _get_time_resolution, + get_datetime_resolution, + get_time_resolution, + parse_datetime, + parse_time, +) + + +class TestTimeResolutionFunctions(unittest.TestCase): + def test_get_time_resolution(self): + self.assertEqual(get_time_resolution("01:23:45"), TimeResolution.Seconds) + self.assertEqual(get_time_resolution("24:00:00"), TimeResolution.Seconds) + self.assertEqual(get_time_resolution("23:21:28,512400"), TimeResolution.Seconds) + self.assertEqual(get_time_resolution("23:21:28.512400"), TimeResolution.Seconds) + self.assertEqual(get_time_resolution("01:23"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("24:00"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("01:23,4567"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("01:23.4567"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("012345"), TimeResolution.Seconds) + self.assertEqual(get_time_resolution("240000"), TimeResolution.Seconds) + self.assertEqual(get_time_resolution("0123"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("2400"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("01"), TimeResolution.Hours) + self.assertEqual(get_time_resolution("24"), TimeResolution.Hours) + self.assertEqual(get_time_resolution("12,5"), TimeResolution.Hours) + self.assertEqual(get_time_resolution("12.5"), TimeResolution.Hours) + self.assertEqual( + get_time_resolution("232128.512400+00:00"), TimeResolution.Seconds + ) + self.assertEqual(get_time_resolution("0123.4567+00:00"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("01.4567+00:00"), TimeResolution.Hours) + self.assertEqual(get_time_resolution("01:23:45+00:00"), TimeResolution.Seconds) + self.assertEqual(get_time_resolution("24:00:00+00:00"), TimeResolution.Seconds) + self.assertEqual( + get_time_resolution("23:21:28.512400+00:00"), TimeResolution.Seconds + ) + self.assertEqual(get_time_resolution("01:23+00:00"), TimeResolution.Minutes) + self.assertEqual(get_time_resolution("24:00+00:00"), TimeResolution.Minutes) + self.assertEqual( + get_time_resolution("01:23.4567+00:00"), TimeResolution.Minutes + ) + self.assertEqual( + get_time_resolution("23:21:28.512400+11:15"), TimeResolution.Seconds + ) + self.assertEqual( + get_time_resolution("23:21:28.512400-12:34"), TimeResolution.Seconds + ) + self.assertEqual( + get_time_resolution("23:21:28.512400Z"), TimeResolution.Seconds + ) + self.assertEqual( + get_time_resolution("06:14:00.000123Z"), TimeResolution.Seconds + ) + + def test_get_datetime_resolution(self): + self.assertEqual( + get_datetime_resolution("2019-06-05T01:03:11.858714"), + TimeResolution.Seconds, + ) + self.assertEqual( + get_datetime_resolution("2019-06-05T01:03:11"), TimeResolution.Seconds + ) + self.assertEqual( + get_datetime_resolution("2019-06-05T01:03"), TimeResolution.Minutes + ) + self.assertEqual(get_datetime_resolution("2019-06-05T01"), TimeResolution.Hours) + + def test_get_time_resolution_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + get_time_resolution(testtuple) + + def test_get_time_resolution_badstr(self): + testtuples = ("A6:14:00.000123Z", "06:14:0B", "bad", "") + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + get_time_resolution(testtuple) + + def test_get_time_resolution_internal(self): + self.assertEqual( + _get_time_resolution(TimeTuple(hh="01", mm="02", ss="03", tz=None)), + TimeResolution.Seconds, + ) + self.assertEqual( + _get_time_resolution(TimeTuple(hh="01", mm="02", ss=None, tz=None)), + TimeResolution.Minutes, + ) + self.assertEqual( + _get_time_resolution(TimeTuple(hh="01", mm=None, ss=None, tz=None)), + TimeResolution.Hours, + ) + + +class TestTimeParserFunctions(unittest.TestCase): + def test_parse_time(self): + testtuples = ( + ("01:23:45", {"hh": "01", "mm": "23", "ss": "45", "tz": None}), + ("24:00:00", {"hh": "24", "mm": "00", "ss": "00", "tz": None}), + ( + "23:21:28,512400", + {"hh": "23", "mm": "21", "ss": "28.512400", "tz": None}, + ), + ( + "23:21:28.512400", + {"hh": "23", "mm": "21", "ss": "28.512400", "tz": None}, + ), + ( + "01:03:11.858714", + {"hh": "01", "mm": "03", "ss": "11.858714", "tz": None}, + ), + ( + "14:43:59.9999997", + {"hh": "14", "mm": "43", "ss": "59.9999997", "tz": None}, + ), + ("01:23", {"hh": "01", "mm": "23", "ss": None, "tz": None}), + ("24:00", {"hh": "24", "mm": "00", "ss": None, "tz": None}), + ("01:23,4567", {"hh": "01", "mm": "23.4567", "ss": None, "tz": None}), + ("01:23.4567", {"hh": "01", "mm": "23.4567", "ss": None, "tz": None}), + ("012345", {"hh": "01", "mm": "23", "ss": "45", "tz": None}), + ("240000", {"hh": "24", "mm": "00", "ss": "00", "tz": None}), + ("232128,512400", {"hh": "23", "mm": "21", "ss": "28.512400", "tz": None}), + ("232128.512400", {"hh": "23", "mm": "21", "ss": "28.512400", "tz": None}), + ("010311.858714", {"hh": "01", "mm": "03", "ss": "11.858714", "tz": None}), + ( + "144359.9999997", + {"hh": "14", "mm": "43", "ss": "59.9999997", "tz": None}, + ), + ("0123", {"hh": "01", "mm": "23", "ss": None, "tz": None}), + ("2400", {"hh": "24", "mm": "00", "ss": None, "tz": None}), + ("01", {"hh": "01", "mm": None, "ss": None, "tz": None}), + ("24", {"hh": "24", "mm": None, "ss": None, "tz": None}), + ("12,5", {"hh": "12.5", "mm": None, "ss": None, "tz": None}), + ("12.5", {"hh": "12.5", "mm": None, "ss": None, "tz": None}), + ( + "232128,512400+00:00", + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "232128.512400+00:00", + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "0123,4567+00:00", + { + "hh": "01", + "mm": "23.4567", + "ss": None, + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "0123.4567+00:00", + { + "hh": "01", + "mm": "23.4567", + "ss": None, + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "01,4567+00:00", + { + "hh": "01.4567", + "mm": None, + "ss": None, + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "01.4567+00:00", + { + "hh": "01.4567", + "mm": None, + "ss": None, + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "01:23:45+00:00", + { + "hh": "01", + "mm": "23", + "ss": "45", + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "24:00:00+00:00", + { + "hh": "24", + "mm": "00", + "ss": "00", + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "23:21:28.512400+00:00", + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "01:23+00:00", + { + "hh": "01", + "mm": "23", + "ss": None, + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "24:00+00:00", + { + "hh": "24", + "mm": "00", + "ss": None, + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "01:23.4567+00:00", + { + "hh": "01", + "mm": "23.4567", + "ss": None, + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + }, + ), + ( + "23:21:28.512400+11:15", + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "11", "15", "+11:15"), + }, + ), + ( + "23:21:28.512400-12:34", + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(True, None, "12", "34", "-12:34"), + }, + ), + ( + "23:21:28.512400Z", + { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, True, None, None, "Z"), + }, + ), + ( + "06:14:00.000123Z", + { + "hh": "06", + "mm": "14", + "ss": "00.000123", + "tz": TimezoneTuple(False, True, None, None, "Z"), + }, + ), + ) + + for testtuple in testtuples: + with mock.patch.object( + aniso8601.time.PythonTimeBuilder, "build_time" + ) as mockBuildTime: + + mockBuildTime.return_value = testtuple[1] + + result = parse_time(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + mockBuildTime.assert_called_once_with(**testtuple[1]) + + def test_parse_time_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + parse_time(testtuple, builder=None) + + def test_parse_time_badstr(self): + testtuples = ( + "A6:14:00.000123Z", + "06:14:0B", + "06:1 :02", + "0000,70:24,9", + "00.27:5332", + "bad", + "", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_time(testtuple, builder=None) + + def test_parse_time_mockbuilder(self): + mockBuilder = mock.Mock() + + expectedargs = {"hh": "01", "mm": "23", "ss": "45", "tz": None} + + mockBuilder.build_time.return_value = expectedargs + + result = parse_time("01:23:45", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_time.assert_called_once_with(**expectedargs) + + mockBuilder = mock.Mock() + + expectedargs = { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "00", "00", "+00:00"), + } + + mockBuilder.build_time.return_value = expectedargs + + result = parse_time("232128.512400+00:00", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_time.assert_called_once_with(**expectedargs) + + mockBuilder = mock.Mock() + + expectedargs = { + "hh": "23", + "mm": "21", + "ss": "28.512400", + "tz": TimezoneTuple(False, None, "11", "15", "+11:15"), + } + + mockBuilder.build_time.return_value = expectedargs + + result = parse_time("23:21:28.512400+11:15", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_time.assert_called_once_with(**expectedargs) + + def test_parse_datetime(self): + testtuples = ( + ( + "2019-06-05T01:03:11,858714", + ( + DateTuple("2019", "06", "05", None, None, None), + TimeTuple("01", "03", "11.858714", None), + ), + ), + ( + "2019-06-05T01:03:11.858714", + ( + DateTuple("2019", "06", "05", None, None, None), + TimeTuple("01", "03", "11.858714", None), + ), + ), + ( + "1981-04-05T23:21:28.512400Z", + ( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple( + "23", + "21", + "28.512400", + TimezoneTuple(False, True, None, None, "Z"), + ), + ), + ), + ( + "1981095T23:21:28.512400-12:34", + ( + DateTuple("1981", None, None, None, None, "095"), + TimeTuple( + "23", + "21", + "28.512400", + TimezoneTuple(True, None, "12", "34", "-12:34"), + ), + ), + ), + ( + "19810405T23:21:28+00", + ( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple( + "23", "21", "28", TimezoneTuple(False, None, "00", None, "+00") + ), + ), + ), + ( + "19810405T23:21:28+00:00", + ( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple( + "23", + "21", + "28", + TimezoneTuple(False, None, "00", "00", "+00:00"), + ), + ), + ), + ) + + for testtuple in testtuples: + with mock.patch.object( + aniso8601.time.PythonTimeBuilder, "build_datetime" + ) as mockBuildDateTime: + + mockBuildDateTime.return_value = testtuple[1] + + result = parse_datetime(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + mockBuildDateTime.assert_called_once_with(*testtuple[1]) + + def test_parse_datetime_spacedelimited(self): + expectedargs = ( + DateTuple("2004", None, None, "53", "6", None), + TimeTuple( + "23", "21", "28.512400", TimezoneTuple(True, None, "12", "34", "-12:34") + ), + ) + + with mock.patch.object( + aniso8601.time.PythonTimeBuilder, "build_datetime" + ) as mockBuildDateTime: + + mockBuildDateTime.return_value = expectedargs + + result = parse_datetime("2004-W53-6 23:21:28.512400-12:34", delimiter=" ") + + self.assertEqual(result, expectedargs) + mockBuildDateTime.assert_called_once_with(*expectedargs) + + def test_parse_datetime_commadelimited(self): + expectedargs = ( + DateTuple("1981", "04", "05", None, None, None), + TimeTuple( + "23", "21", "28.512400", TimezoneTuple(False, True, None, None, "Z") + ), + ) + + with mock.patch.object( + aniso8601.time.PythonTimeBuilder, "build_datetime" + ) as mockBuildDateTime: + + mockBuildDateTime.return_value = expectedargs + + result = parse_datetime("1981-04-05,23:21:28,512400Z", delimiter=",") + + self.assertEqual(result, expectedargs) + mockBuildDateTime.assert_called_once_with(*expectedargs) + + def test_parse_datetime_baddelimiter(self): + testtuples = ( + "1981-04-05,23:21:28,512400Z", + "2004-W53-6 23:21:28.512400-12:3", + "1981040523:21:28", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_datetime(testtuple, builder=None) + + def test_parse_datetime_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + parse_datetime(testtuple, builder=None) + + def test_parse_datetime_badstr(self): + testtuples = ( + "1981-04-05TA6:14:00.000123Z", + "2004-W53-6T06:14:0B", + "2014-01-230T23:21:28+00", + "201401230T01:03:11.858714", + "9999 W53T49", + "9T0000,70:24,9", + "bad", + "", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_datetime(testtuple, builder=None) + + def test_parse_datetime_mockbuilder(self): + mockBuilder = mock.Mock() + + expectedargs = ( + DateTuple("1981", None, None, None, None, "095"), + TimeTuple( + "23", "21", "28.512400", TimezoneTuple(True, None, "12", "34", "-12:34") + ), + ) + + mockBuilder.build_datetime.return_value = expectedargs + + result = parse_datetime("1981095T23:21:28.512400-12:34", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_datetime.assert_called_once_with(*expectedargs) diff --git a/libs/aniso8601/tests/test_timezone.py b/libs/aniso8601/tests/test_timezone.py new file mode 100644 index 000000000..5df9671f1 --- /dev/null +++ b/libs/aniso8601/tests/test_timezone.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import unittest + +import aniso8601 +from aniso8601.exceptions import ISOFormatError +from aniso8601.tests.compat import mock +from aniso8601.timezone import parse_timezone + + +class TestTimezoneParserFunctions(unittest.TestCase): + def test_parse_timezone(self): + testtuples = ( + ("Z", {"negative": False, "Z": True, "name": "Z"}), + ("+00:00", {"negative": False, "hh": "00", "mm": "00", "name": "+00:00"}), + ("+01:00", {"negative": False, "hh": "01", "mm": "00", "name": "+01:00"}), + ("-01:00", {"negative": True, "hh": "01", "mm": "00", "name": "-01:00"}), + ("+00:12", {"negative": False, "hh": "00", "mm": "12", "name": "+00:12"}), + ("+01:23", {"negative": False, "hh": "01", "mm": "23", "name": "+01:23"}), + ("-01:23", {"negative": True, "hh": "01", "mm": "23", "name": "-01:23"}), + ("+0000", {"negative": False, "hh": "00", "mm": "00", "name": "+0000"}), + ("+0100", {"negative": False, "hh": "01", "mm": "00", "name": "+0100"}), + ("-0100", {"negative": True, "hh": "01", "mm": "00", "name": "-0100"}), + ("+0012", {"negative": False, "hh": "00", "mm": "12", "name": "+0012"}), + ("+0123", {"negative": False, "hh": "01", "mm": "23", "name": "+0123"}), + ("-0123", {"negative": True, "hh": "01", "mm": "23", "name": "-0123"}), + ("+00", {"negative": False, "hh": "00", "mm": None, "name": "+00"}), + ("+01", {"negative": False, "hh": "01", "mm": None, "name": "+01"}), + ("-01", {"negative": True, "hh": "01", "mm": None, "name": "-01"}), + ("+12", {"negative": False, "hh": "12", "mm": None, "name": "+12"}), + ("-12", {"negative": True, "hh": "12", "mm": None, "name": "-12"}), + ) + + for testtuple in testtuples: + with mock.patch.object( + aniso8601.timezone.PythonTimeBuilder, "build_timezone" + ) as mockBuildTimezone: + + mockBuildTimezone.return_value = testtuple[1] + + result = parse_timezone(testtuple[0]) + + self.assertEqual(result, testtuple[1]) + mockBuildTimezone.assert_called_once_with(**testtuple[1]) + + def test_parse_timezone_badtype(self): + testtuples = (None, 1, False, 1.234) + + for testtuple in testtuples: + with self.assertRaises(ValueError): + parse_timezone(testtuple, builder=None) + + def test_parse_timezone_badstr(self): + testtuples = ( + "+1", + "-00", + "-0000", + "-00:00", + "01", + "0123", + "@12:34", + "Y", + " Z", + "Z ", + " Z ", + "bad", + "", + ) + + for testtuple in testtuples: + with self.assertRaises(ISOFormatError): + parse_timezone(testtuple, builder=None) + + def test_parse_timezone_mockbuilder(self): + mockBuilder = mock.Mock() + + expectedargs = {"negative": False, "Z": True, "name": "Z"} + + mockBuilder.build_timezone.return_value = expectedargs + + result = parse_timezone("Z", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_timezone.assert_called_once_with(**expectedargs) + + mockBuilder = mock.Mock() + + expectedargs = {"negative": False, "hh": "00", "mm": "00", "name": "+00:00"} + + mockBuilder.build_timezone.return_value = expectedargs + + result = parse_timezone("+00:00", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_timezone.assert_called_once_with(**expectedargs) + + mockBuilder = mock.Mock() + + expectedargs = {"negative": True, "hh": "01", "mm": "23", "name": "-01:23"} + + mockBuilder.build_timezone.return_value = expectedargs + + result = parse_timezone("-01:23", builder=mockBuilder) + + self.assertEqual(result, expectedargs) + mockBuilder.build_timezone.assert_called_once_with(**expectedargs) + + def test_parse_timezone_negativezero(self): + # A 0 offset cannot be negative + with self.assertRaises(ISOFormatError): + parse_timezone("-00:00", builder=None) + + with self.assertRaises(ISOFormatError): + parse_timezone("-0000", builder=None) + + with self.assertRaises(ISOFormatError): + parse_timezone("-00", builder=None) diff --git a/libs/aniso8601/tests/test_utcoffset.py b/libs/aniso8601/tests/test_utcoffset.py new file mode 100644 index 000000000..11fa4f7d9 --- /dev/null +++ b/libs/aniso8601/tests/test_utcoffset.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import datetime +import pickle +import unittest + +from aniso8601.utcoffset import UTCOffset + + +class TestUTCOffset(unittest.TestCase): + def test_pickle(self): + # Make sure timezone objects are pickleable + testutcoffset = UTCOffset(name="UTC", minutes=0) + + utcoffsetpickle = pickle.dumps(testutcoffset) + + resultutcoffset = pickle.loads(utcoffsetpickle) + + self.assertEqual(resultutcoffset._name, testutcoffset._name) + self.assertEqual(resultutcoffset._utcdelta, testutcoffset._utcdelta) + + def test_repr(self): + self.assertEqual(str(UTCOffset(minutes=0)), "+0:00:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=60)), "+1:00:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=-60)), "-1:00:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=12)), "+0:12:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=-12)), "-0:12:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=83)), "+1:23:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=-83)), "-1:23:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=1440)), "+1 day, 0:00:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=-1440)), "-1 day, 0:00:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=2967)), "+2 days, 1:27:00 UTC") + + self.assertEqual(str(UTCOffset(minutes=-2967)), "-2 days, 1:27:00 UTC") + + def test_dst(self): + tzinfoobject = UTCOffset(minutes=240) + # This would raise ISOFormatError or a TypeError if dst info is invalid + result = datetime.datetime.now(tzinfoobject) + # Hacky way to make sure the tzinfo is what we'd expect + self.assertEqual(result.tzinfo.utcoffset(None), datetime.timedelta(hours=4)) diff --git a/libs/aniso8601/time.py b/libs/aniso8601/time.py new file mode 100644 index 000000000..31fab048e --- /dev/null +++ b/libs/aniso8601/time.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +from aniso8601.builders import TupleBuilder +from aniso8601.builders.python import PythonTimeBuilder +from aniso8601.compat import is_string +from aniso8601.date import parse_date +from aniso8601.decimalfraction import normalize +from aniso8601.exceptions import ISOFormatError +from aniso8601.resolution import TimeResolution +from aniso8601.timezone import parse_timezone + +TIMEZONE_DELIMITERS = ["Z", "+", "-"] + + +def get_time_resolution(isotimestr): + # Valid time formats are: + # + # hh:mm:ss + # hhmmss + # hh:mm + # hhmm + # hh + # hh:mm:ssZ + # hhmmssZ + # hh:mmZ + # hhmmZ + # hhZ + # hh:mm:ss±hh:mm + # hhmmss±hh:mm + # hh:mm±hh:mm + # hhmm±hh:mm + # hh±hh:mm + # hh:mm:ss±hhmm + # hhmmss±hhmm + # hh:mm±hhmm + # hhmm±hhmm + # hh±hhmm + # hh:mm:ss±hh + # hhmmss±hh + # hh:mm±hh + # hhmm±hh + # hh±hh + isotimetuple = parse_time(isotimestr, builder=TupleBuilder) + + return _get_time_resolution(isotimetuple) + + +def get_datetime_resolution(isodatetimestr, delimiter="T"): + # <date>T<time> + # + # Time part cannot be omittted so return time resolution + isotimetuple = parse_datetime( + isodatetimestr, delimiter=delimiter, builder=TupleBuilder + ).time + + return _get_time_resolution(isotimetuple) + + +def _get_time_resolution(isotimetuple): + if isotimetuple.ss is not None: + return TimeResolution.Seconds + + if isotimetuple.mm is not None: + return TimeResolution.Minutes + + return TimeResolution.Hours + + +def parse_time(isotimestr, builder=PythonTimeBuilder): + # Given a string in any ISO 8601 time format, return a datetime.time object + # that corresponds to the given time. Fixed offset tzdata will be included + # if UTC offset is given in the input string. Valid time formats are: + # + # hh:mm:ss + # hhmmss + # hh:mm + # hhmm + # hh + # hh:mm:ssZ + # hhmmssZ + # hh:mmZ + # hhmmZ + # hhZ + # hh:mm:ss±hh:mm + # hhmmss±hh:mm + # hh:mm±hh:mm + # hhmm±hh:mm + # hh±hh:mm + # hh:mm:ss±hhmm + # hhmmss±hhmm + # hh:mm±hhmm + # hhmm±hhmm + # hh±hhmm + # hh:mm:ss±hh + # hhmmss±hh + # hh:mm±hh + # hhmm±hh + # hh±hh + if is_string(isotimestr) is False: + raise ValueError("Time must be string.") + + if len(isotimestr) == 0: + raise ISOFormatError('"{0}" is not a valid ISO 8601 time.'.format(isotimestr)) + + timestr = normalize(isotimestr) + + hourstr = None + minutestr = None + secondstr = None + tzstr = None + + fractionalstr = None + + # Split out the timezone + for delimiter in TIMEZONE_DELIMITERS: + delimiteridx = timestr.find(delimiter) + + if delimiteridx != -1: + tzstr = timestr[delimiteridx:] + timestr = timestr[0:delimiteridx] + + # Split out the fractional component + if timestr.find(".") != -1: + timestr, fractionalstr = timestr.split(".", 1) + + if fractionalstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 time.'.format(isotimestr) + ) + + if len(timestr) == 2: + # hh + hourstr = timestr + elif len(timestr) == 4 or len(timestr) == 5: + # hh:mm + # hhmm + if timestr.count(":") == 1: + hourstr, minutestr = timestr.split(":") + else: + hourstr = timestr[0:2] + minutestr = timestr[2:] + elif len(timestr) == 6 or len(timestr) == 8: + # hh:mm:ss + # hhmmss + if timestr.count(":") == 2: + hourstr, minutestr, secondstr = timestr.split(":") + else: + hourstr = timestr[0:2] + minutestr = timestr[2:4] + secondstr = timestr[4:] + else: + raise ISOFormatError('"{0}" is not a valid ISO 8601 time.'.format(isotimestr)) + + for componentstr in [hourstr, minutestr, secondstr]: + if componentstr is not None and componentstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 time.'.format(isotimestr) + ) + + if fractionalstr is not None: + if secondstr is not None: + secondstr = secondstr + "." + fractionalstr + elif minutestr is not None: + minutestr = minutestr + "." + fractionalstr + else: + hourstr = hourstr + "." + fractionalstr + + if tzstr is None: + tz = None + else: + tz = parse_timezone(tzstr, builder=TupleBuilder) + + return builder.build_time(hh=hourstr, mm=minutestr, ss=secondstr, tz=tz) + + +def parse_datetime(isodatetimestr, delimiter="T", builder=PythonTimeBuilder): + # Given a string in ISO 8601 date time format, return a datetime.datetime + # object that corresponds to the given date time. + # By default, the ISO 8601 specified T delimiter is used to split the + # date and time (<date>T<time>). Fixed offset tzdata will be included + # if UTC offset is given in the input string. + if is_string(isodatetimestr) is False: + raise ValueError("Date time must be string.") + + if delimiter not in isodatetimestr: + raise ISOFormatError( + 'Delimiter "{0}" is not in combined date time ' + 'string "{1}".'.format(delimiter, isodatetimestr) + ) + + isodatestr, isotimestr = isodatetimestr.split(delimiter, 1) + + datepart = parse_date(isodatestr, builder=TupleBuilder) + + timepart = parse_time(isotimestr, builder=TupleBuilder) + + return builder.build_datetime(datepart, timepart) diff --git a/libs/aniso8601/timezone.py b/libs/aniso8601/timezone.py new file mode 100644 index 000000000..3b66105bd --- /dev/null +++ b/libs/aniso8601/timezone.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +from aniso8601.builders.python import PythonTimeBuilder +from aniso8601.compat import is_string +from aniso8601.exceptions import ISOFormatError + + +def parse_timezone(tzstr, builder=PythonTimeBuilder): + # tzstr can be Z, ±hh:mm, ±hhmm, ±hh + if is_string(tzstr) is False: + raise ValueError("Time zone must be string.") + + if len(tzstr) == 1 and tzstr[0] == "Z": + return builder.build_timezone(negative=False, Z=True, name=tzstr) + elif len(tzstr) == 6: + # ±hh:mm + hourstr = tzstr[1:3] + minutestr = tzstr[4:6] + + if tzstr[0] == "-" and hourstr == "00" and minutestr == "00": + raise ISOFormatError("Negative ISO 8601 time offset must not " "be 0.") + elif len(tzstr) == 5: + # ±hhmm + hourstr = tzstr[1:3] + minutestr = tzstr[3:5] + + if tzstr[0] == "-" and hourstr == "00" and minutestr == "00": + raise ISOFormatError("Negative ISO 8601 time offset must not " "be 0.") + elif len(tzstr) == 3: + # ±hh + hourstr = tzstr[1:3] + minutestr = None + + if tzstr[0] == "-" and hourstr == "00": + raise ISOFormatError("Negative ISO 8601 time offset must not " "be 0.") + else: + raise ISOFormatError('"{0}" is not a valid ISO 8601 time offset.'.format(tzstr)) + + for componentstr in [hourstr, minutestr]: + if componentstr is not None: + if componentstr.isdigit() is False: + raise ISOFormatError( + '"{0}" is not a valid ISO 8601 time offset.'.format(tzstr) + ) + + if tzstr[0] == "+": + return builder.build_timezone( + negative=False, hh=hourstr, mm=minutestr, name=tzstr + ) + + if tzstr[0] == "-": + return builder.build_timezone( + negative=True, hh=hourstr, mm=minutestr, name=tzstr + ) + + raise ISOFormatError('"{0}" is not a valid ISO 8601 time offset.'.format(tzstr)) diff --git a/libs/aniso8601/utcoffset.py b/libs/aniso8601/utcoffset.py new file mode 100644 index 000000000..11872a01e --- /dev/null +++ b/libs/aniso8601/utcoffset.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Brandon Nielsen +# All rights reserved. +# +# This software may be modified and distributed under the terms +# of the BSD license. See the LICENSE file for details. + +import datetime + + +class UTCOffset(datetime.tzinfo): + def __init__(self, name=None, minutes=None): + # We build an offset in this manner since the + # tzinfo class must have an init + # "method that can be called with no arguments" + self._name = name + + if minutes is not None: + self._utcdelta = datetime.timedelta(minutes=minutes) + else: + self._utcdelta = None + + def __repr__(self): + if self._utcdelta >= datetime.timedelta(hours=0): + return "+{0} UTC".format(self._utcdelta) + + # From the docs: + # String representations of timedelta objects are normalized + # similarly to their internal representation. This leads to + # somewhat unusual results for negative timedeltas. + + # Clean this up for printing purposes + # Negative deltas start at -1 day + correcteddays = abs(self._utcdelta.days + 1) + + # Negative deltas have a positive seconds + deltaseconds = (24 * 60 * 60) - self._utcdelta.seconds + + # (24 hours / day) * (60 minutes / hour) * (60 seconds / hour) + days, remainder = divmod(deltaseconds, 24 * 60 * 60) + + # (1 hour) * (60 minutes / hour) * (60 seconds / hour) + hours, remainder = divmod(remainder, 1 * 60 * 60) + + # (1 minute) * (60 seconds / minute) + minutes, seconds = divmod(remainder, 1 * 60) + + # Add any remaining days to the correcteddays count + correcteddays += days + + if correcteddays == 0: + return "-{0}:{1:02}:{2:02} UTC".format(hours, minutes, seconds) + elif correcteddays == 1: + return "-1 day, {0}:{1:02}:{2:02} UTC".format(hours, minutes, seconds) + + return "-{0} days, {1}:{2:02}:{3:02} UTC".format( + correcteddays, hours, minutes, seconds + ) + + def utcoffset(self, dt): + return self._utcdelta + + def tzname(self, dt): + return self._name + + def dst(self, dt): + # ISO 8601 specifies offsets should be different if DST is required, + # instead of allowing for a DST to be specified + # https://docs.python.org/2/library/datetime.html#datetime.tzinfo.dst + return datetime.timedelta(0) |