123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705 |
- # -*- 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)
|