You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
615 lines
18 KiB
615 lines
18 KiB
# -*- 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)
|