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.
bazarr/libs/aniso8601/builders/python.py

706 lines
22 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 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)