678 lines
26 KiB
Python
678 lines
26 KiB
Python
"""
|
|
Large parts of this module are taken from the ``isodate`` package.
|
|
https://pypi.org/project/isodate/
|
|
Modifications are made to isodate features to allow compatibility with
|
|
XSD dates and durations that are not necessarily valid ISO8601 strings.
|
|
|
|
Copyright (c) 2024, Ashley Sommer, and RDFLib contributors
|
|
Copyright (c) 2021, Hugo van Kemenade and contributors
|
|
Copyright (c) 2009-2018, Gerhard Weis and contributors
|
|
Copyright (c) 2009, Gerhard Weis
|
|
All rights reserved.
|
|
|
|
Redistribution and use in source and binary forms, with or without
|
|
modification, are permitted provided that the following conditions are met:
|
|
- Redistributions of source code must retain the above copyright
|
|
notice, this list of conditions and the following disclaimer.
|
|
- Redistributions in binary form must reproduce the above copyright
|
|
notice, this list of conditions and the following disclaimer in the
|
|
documentation and/or other materials provided with the distribution.
|
|
- Neither the name of the <organization> nor the
|
|
names of its contributors may be used to endorse or promote products
|
|
derived from this software without specific prior written permission.
|
|
|
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
|
|
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from datetime import date, datetime, time, timedelta
|
|
from decimal import ROUND_FLOOR, Decimal
|
|
from typing import List, Tuple, Union, cast
|
|
|
|
if sys.version_info[:3] < (3, 11, 0):
|
|
from isodate import parse_date, parse_datetime, parse_time
|
|
else:
|
|
# On python 3.11, use the built-in parsers
|
|
parse_date = date.fromisoformat
|
|
parse_datetime = datetime.fromisoformat
|
|
parse_time = time.fromisoformat
|
|
|
|
|
|
def fquotmod(
|
|
val: Decimal, low: Union[Decimal, int], high: Union[Decimal, int]
|
|
) -> Tuple[int, Decimal]:
|
|
"""
|
|
A divmod function with boundaries.
|
|
|
|
"""
|
|
# assumes that all the maths is done with Decimals.
|
|
# divmod for Decimal uses truncate instead of floor as builtin
|
|
# divmod, so we have to do it manually here.
|
|
a: Decimal = val - low
|
|
b: Union[Decimal, int] = high - low
|
|
div: Decimal = (a / b).to_integral(ROUND_FLOOR)
|
|
mod: Decimal = a - div * b
|
|
# if we were not using Decimal, it would look like this.
|
|
# div, mod = divmod(val - low, high - low)
|
|
mod += low
|
|
return int(div), mod
|
|
|
|
|
|
def max_days_in_month(year: int, month: int) -> int:
|
|
"""
|
|
Determines the number of days of a specific month in a specific year.
|
|
"""
|
|
if month in (1, 3, 5, 7, 8, 10, 12):
|
|
return 31
|
|
if month in (4, 6, 9, 11):
|
|
return 30
|
|
if month < 1 or month > 12:
|
|
raise ValueError("Month must be in 1..12")
|
|
# Month is February
|
|
if ((year % 400) == 0) or ((year % 100) != 0) and ((year % 4) == 0):
|
|
return 29
|
|
return 28
|
|
|
|
|
|
class Duration:
|
|
"""
|
|
A class which represents a duration.
|
|
|
|
The difference to datetime.timedelta is, that this class handles also
|
|
differences given in years and months.
|
|
A Duration treats differences given in year, months separately from all
|
|
other components.
|
|
|
|
A Duration can be used almost like any timedelta object, however there
|
|
are some restrictions:
|
|
- It is not really possible to compare Durations, because it is unclear,
|
|
whether a duration of 1 year is bigger than 365 days or not.
|
|
- Equality is only tested between the two (year, month vs. timedelta)
|
|
basic components.
|
|
|
|
A Duration can also be converted into a datetime object, but this requires
|
|
a start date or an end date.
|
|
|
|
The algorithm to add a duration to a date is defined at
|
|
http://www.w3.org/TR/xmlschema-2/#adding-durations-to-dateTimes
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
days: float = 0,
|
|
seconds: float = 0,
|
|
microseconds: float = 0,
|
|
milliseconds: float = 0,
|
|
minutes: float = 0,
|
|
hours: float = 0,
|
|
weeks: float = 0,
|
|
months: Union[Decimal, float, int, str] = 0,
|
|
years: Union[Decimal, float, int, str] = 0,
|
|
):
|
|
"""
|
|
Initialise this Duration instance with the given parameters.
|
|
"""
|
|
if not isinstance(months, Decimal):
|
|
months = Decimal(str(months))
|
|
if not isinstance(years, Decimal):
|
|
years = Decimal(str(years))
|
|
new_years, months = fquotmod(months, 0, 12)
|
|
self.months = months
|
|
self.years = Decimal(years + new_years)
|
|
self.tdelta = timedelta(
|
|
days, seconds, microseconds, milliseconds, minutes, hours, weeks
|
|
)
|
|
if self.years < 0 and self.tdelta.days < 0:
|
|
raise ValueError("Duration cannot have negative years and negative days")
|
|
|
|
def __getstate__(self):
|
|
return self.__dict__
|
|
|
|
def __setstate__(self, state):
|
|
self.__dict__.update(state)
|
|
|
|
def __getattr__(self, name):
|
|
"""
|
|
Provide direct access to attributes of included timedelta instance.
|
|
"""
|
|
return getattr(self.tdelta, name)
|
|
|
|
def __str__(self):
|
|
"""
|
|
Return a string representation of this duration similar to timedelta.
|
|
"""
|
|
params = []
|
|
if self.years:
|
|
params.append("%d years" % self.years)
|
|
if self.months:
|
|
fmt = "%d months"
|
|
if self.months <= 1:
|
|
fmt = "%d month"
|
|
params.append(fmt % self.months)
|
|
params.append(str(self.tdelta))
|
|
return ", ".join(params)
|
|
|
|
def __repr__(self):
|
|
"""
|
|
Return a string suitable for repr(x) calls.
|
|
"""
|
|
return "%s.%s(%d, %d, %d, years=%s, months=%s)" % (
|
|
self.__class__.__module__,
|
|
self.__class__.__name__,
|
|
self.tdelta.days,
|
|
self.tdelta.seconds,
|
|
self.tdelta.microseconds,
|
|
str(self.years),
|
|
str(self.months),
|
|
)
|
|
|
|
def __hash__(self):
|
|
"""
|
|
Return a hash of this instance so that it can be used in, for
|
|
example, dicts and sets.
|
|
"""
|
|
return hash((self.tdelta, self.months, self.years))
|
|
|
|
def __neg__(self):
|
|
"""
|
|
A simple unary minus.
|
|
|
|
Returns a new Duration instance with all it's negated.
|
|
"""
|
|
negduration = Duration(years=-self.years, months=-self.months)
|
|
negduration.tdelta = -self.tdelta
|
|
return negduration
|
|
|
|
def __add__(self, other: Union[Duration, timedelta, date, datetime]):
|
|
"""
|
|
Durations can be added with Duration, timedelta, date and datetime
|
|
objects.
|
|
"""
|
|
if isinstance(other, Duration):
|
|
newduration = Duration(
|
|
years=self.years + other.years, months=self.months + other.months
|
|
)
|
|
newduration.tdelta = self.tdelta + other.tdelta
|
|
return newduration
|
|
elif isinstance(other, timedelta):
|
|
newduration = Duration(years=self.years, months=self.months)
|
|
newduration.tdelta = self.tdelta + other
|
|
return newduration
|
|
try:
|
|
# try anything that looks like a date or datetime
|
|
# 'other' has attributes year, month, day
|
|
# and relies on 'timedelta + other' being implemented
|
|
if not (float(self.years).is_integer() and float(self.months).is_integer()):
|
|
raise ValueError(
|
|
"fractional years or months not supported for date calculations"
|
|
)
|
|
newmonth: Decimal = Decimal(other.month) + self.months
|
|
carry, newmonth = fquotmod(newmonth, 1, 13)
|
|
newyear: int = other.year + int(self.years) + carry
|
|
maxdays: int = max_days_in_month(newyear, int(newmonth))
|
|
newday: Union[int, float]
|
|
if other.day > maxdays:
|
|
newday = maxdays
|
|
else:
|
|
newday = other.day
|
|
newdt = other.replace(year=newyear, month=int(newmonth), day=newday)
|
|
# does a timedelta + date/datetime
|
|
return self.tdelta + newdt
|
|
except AttributeError:
|
|
# other probably was not a date/datetime compatible object
|
|
pass
|
|
# we have tried everything .... return a NotImplemented
|
|
return NotImplemented
|
|
|
|
__radd__ = __add__
|
|
|
|
def __mul__(self, other):
|
|
if isinstance(other, int):
|
|
newduration = Duration(years=self.years * other, months=self.months * other)
|
|
newduration.tdelta = self.tdelta * other
|
|
return newduration
|
|
return NotImplemented
|
|
|
|
__rmul__ = __mul__
|
|
|
|
def __sub__(self, other: Union[Duration, timedelta]):
|
|
"""
|
|
It is possible to subtract Duration and timedelta objects from Duration
|
|
objects.
|
|
"""
|
|
if isinstance(other, Duration):
|
|
newduration = Duration(
|
|
years=self.years - other.years, months=self.months - other.months
|
|
)
|
|
newduration.tdelta = self.tdelta - other.tdelta
|
|
return newduration
|
|
try:
|
|
# do maths with our timedelta object ....
|
|
newduration = Duration(years=self.years, months=self.months)
|
|
newduration.tdelta = self.tdelta - other
|
|
return newduration
|
|
except TypeError:
|
|
# looks like timedelta - other is not implemented
|
|
pass
|
|
return NotImplemented
|
|
|
|
def __rsub__(self, other: Union[timedelta, date, datetime]):
|
|
"""
|
|
It is possible to subtract Duration objects from date, datetime and
|
|
timedelta objects.
|
|
"""
|
|
# TODO: there is some weird behaviour in date - timedelta ...
|
|
# if timedelta has seconds or microseconds set, then
|
|
# date - timedelta != date + (-timedelta)
|
|
# for now we follow this behaviour to avoid surprises when mixing
|
|
# timedeltas with Durations, but in case this ever changes in
|
|
# the stdlib we can just do:
|
|
# return -self + other
|
|
# instead of all the current code
|
|
|
|
if isinstance(other, timedelta):
|
|
tmpdur = Duration()
|
|
tmpdur.tdelta = other
|
|
return tmpdur - self
|
|
try:
|
|
# check if other behaves like a date/datetime object
|
|
# does it have year, month, day and replace?
|
|
if not (float(self.years).is_integer() and float(self.months).is_integer()):
|
|
raise ValueError(
|
|
"fractional years or months not supported for date calculations"
|
|
)
|
|
newmonth: Decimal = Decimal(other.month) - self.months
|
|
carry, newmonth = fquotmod(newmonth, 1, 13)
|
|
newyear: int = other.year - int(self.years) + carry
|
|
maxdays: int = max_days_in_month(newyear, int(newmonth))
|
|
newday: Union[int, float]
|
|
if other.day > maxdays:
|
|
newday = maxdays
|
|
else:
|
|
newday = other.day
|
|
newdt = other.replace(year=newyear, month=int(newmonth), day=newday)
|
|
return newdt - self.tdelta
|
|
except AttributeError:
|
|
# other probably was not compatible with data/datetime
|
|
pass
|
|
return NotImplemented
|
|
|
|
def __eq__(self, other):
|
|
"""
|
|
If the years, month part and the timedelta part are both equal, then
|
|
the two Durations are considered equal.
|
|
"""
|
|
if isinstance(other, Duration):
|
|
if (self.years * 12 + self.months) == (
|
|
other.years * 12 + other.months
|
|
) and self.tdelta == other.tdelta:
|
|
return True
|
|
return False
|
|
# check if other con be compared against timedelta object
|
|
# will raise an AssertionError when optimisation is off
|
|
if self.years == 0 and self.months == 0:
|
|
return self.tdelta == other
|
|
return False
|
|
|
|
def __ne__(self, other):
|
|
"""
|
|
If the years, month part or the timedelta part is not equal, then
|
|
the two Durations are considered not equal.
|
|
"""
|
|
if isinstance(other, Duration):
|
|
if (self.years * 12 + self.months) != (
|
|
other.years * 12 + other.months
|
|
) or self.tdelta != other.tdelta:
|
|
return True
|
|
return False
|
|
# check if other can be compared against timedelta object
|
|
# will raise an AssertionError when optimisation is off
|
|
if self.years == 0 and self.months == 0:
|
|
return self.tdelta != other
|
|
return True
|
|
|
|
def totimedelta(self, start=None, end=None):
|
|
"""
|
|
Convert this duration into a timedelta object.
|
|
|
|
This method requires a start datetime or end datetime, but raises
|
|
an exception if both are given.
|
|
"""
|
|
if start is None and end is None:
|
|
raise ValueError("start or end required")
|
|
if start is not None and end is not None:
|
|
raise ValueError("only start or end allowed")
|
|
if start is not None:
|
|
return (start + self) - start
|
|
return end - (end - self)
|
|
|
|
|
|
ISO8601_PERIOD_REGEX = re.compile(
|
|
r"^(?P<sign>[+-])?"
|
|
r"P(?!\b)"
|
|
r"(?P<years>[0-9]+([,.][0-9]+)?Y)?"
|
|
r"(?P<months>[0-9]+([,.][0-9]+)?M)?"
|
|
r"(?P<weeks>[0-9]+([,.][0-9]+)?W)?"
|
|
r"(?P<days>[0-9]+([,.][0-9]+)?D)?"
|
|
r"((?P<separator>T)(?P<hours>[0-9]+([,.][0-9]+)?H)?"
|
|
r"(?P<minutes>[0-9]+([,.][0-9]+)?M)?"
|
|
r"(?P<seconds>[0-9]+([,.][0-9]+)?S)?)?$"
|
|
)
|
|
# regular expression to parse ISO duration strings.
|
|
|
|
|
|
def parse_xsd_duration(
|
|
dur_string: str, as_timedelta_if_possible: bool = True
|
|
) -> Union[Duration, timedelta]:
|
|
"""
|
|
Parses an ISO 8601 durations into datetime.timedelta or Duration objects.
|
|
|
|
If the ISO date string does not contain years or months, a timedelta
|
|
instance is returned, else a Duration instance is returned.
|
|
|
|
The following duration formats are supported:
|
|
-``PnnW`` duration in weeks
|
|
-``PnnYnnMnnDTnnHnnMnnS`` complete duration specification
|
|
-``PYYYYMMDDThhmmss`` basic alternative complete date format
|
|
-``PYYYY-MM-DDThh:mm:ss`` extended alternative complete date format
|
|
-``PYYYYDDDThhmmss`` basic alternative ordinal date format
|
|
-``PYYYY-DDDThh:mm:ss`` extended alternative ordinal date format
|
|
|
|
The '-' is optional.
|
|
|
|
Limitations: ISO standard defines some restrictions about where to use
|
|
fractional numbers and which component and format combinations are
|
|
allowed. This parser implementation ignores all those restrictions and
|
|
returns something when it is able to find all necessary components.
|
|
In detail:
|
|
- it does not check, whether only the last component has fractions.
|
|
- it allows weeks specified with all other combinations
|
|
The alternative format does not support durations with years, months or
|
|
days set to 0.
|
|
"""
|
|
if not isinstance(dur_string, str):
|
|
raise TypeError(f"Expecting a string: {dur_string!r}")
|
|
match = ISO8601_PERIOD_REGEX.match(dur_string)
|
|
if not match:
|
|
# try alternative format:
|
|
if dur_string.startswith("P"):
|
|
durdt = parse_datetime(dur_string[1:])
|
|
if as_timedelta_if_possible and durdt.year == 0 and durdt.month == 0:
|
|
# FIXME: currently not possible in alternative format
|
|
# create timedelta
|
|
return timedelta(
|
|
days=durdt.day,
|
|
seconds=durdt.second,
|
|
microseconds=durdt.microsecond,
|
|
minutes=durdt.minute,
|
|
hours=durdt.hour,
|
|
)
|
|
else:
|
|
# create Duration
|
|
return Duration(
|
|
days=durdt.day,
|
|
seconds=durdt.second,
|
|
microseconds=durdt.microsecond,
|
|
minutes=durdt.minute,
|
|
hours=durdt.hour,
|
|
months=durdt.month,
|
|
years=durdt.year,
|
|
)
|
|
raise ValueError("Unable to parse duration string " + dur_string)
|
|
groups = match.groupdict()
|
|
for key, val in groups.items():
|
|
if key not in ("separator", "sign"):
|
|
if val is None:
|
|
groups[key] = "0n"
|
|
# print groups[key]
|
|
if key in ("years", "months"):
|
|
groups[key] = Decimal(groups[key][:-1].replace(",", "."))
|
|
else:
|
|
# these values are passed into a timedelta object,
|
|
# which works with floats.
|
|
groups[key] = float(groups[key][:-1].replace(",", "."))
|
|
ret: Union[Duration, timedelta]
|
|
if as_timedelta_if_possible and groups["years"] == 0 and groups["months"] == 0:
|
|
ret = timedelta(
|
|
days=groups["days"], # type: ignore[arg-type]
|
|
hours=groups["hours"], # type: ignore[arg-type]
|
|
minutes=groups["minutes"], # type: ignore[arg-type]
|
|
seconds=groups["seconds"], # type: ignore[arg-type]
|
|
weeks=groups["weeks"], # type: ignore[arg-type]
|
|
)
|
|
if groups["sign"] == "-":
|
|
ret = timedelta(0) - ret
|
|
else:
|
|
ret = Duration(
|
|
years=cast(Decimal, groups["years"]),
|
|
months=cast(Decimal, groups["months"]),
|
|
days=groups["days"], # type: ignore[arg-type]
|
|
hours=groups["hours"], # type: ignore[arg-type]
|
|
minutes=groups["minutes"], # type: ignore[arg-type]
|
|
seconds=groups["seconds"], # type: ignore[arg-type]
|
|
weeks=groups["weeks"], # type: ignore[arg-type]
|
|
)
|
|
if groups["sign"] == "-":
|
|
ret = Duration(0) - ret
|
|
|
|
return ret
|
|
|
|
|
|
def duration_isoformat(tdt: Union[Duration, timedelta], in_weeks: bool = False) -> str:
|
|
if not in_weeks:
|
|
ret: List[str] = []
|
|
minus = False
|
|
has_year_or_month = False
|
|
if isinstance(tdt, Duration):
|
|
if tdt.years == 0 and tdt.months == 0:
|
|
pass # don't do anything, we have no year or month
|
|
else:
|
|
has_year_or_month = True
|
|
months = tdt.years * 12 + tdt.months
|
|
if months < 0:
|
|
minus = True
|
|
months = abs(months)
|
|
# We can use divmod instead of fquotmod here because its month_count
|
|
# not month_index, and we don't have any negative months at this point.
|
|
new_years, new_months = divmod(months, 12)
|
|
if new_years:
|
|
ret.append(str(new_years) + "Y")
|
|
if tdt.months:
|
|
ret.append(str(new_months) + "M")
|
|
tdt = tdt.tdelta
|
|
usecs: int = ((tdt.days * 86400) + tdt.seconds) * 1000000 + tdt.microseconds
|
|
if usecs < 0:
|
|
if minus:
|
|
raise ValueError(
|
|
"Duration cannot have negative years and negative days"
|
|
)
|
|
elif has_year_or_month:
|
|
raise ValueError(
|
|
"Duration cannot have positive years and months but negative days"
|
|
)
|
|
minus = True
|
|
usecs = abs(usecs)
|
|
if usecs == 0:
|
|
# No delta parts other than years and months
|
|
pass
|
|
else:
|
|
seconds, usecs = divmod(usecs, 1000000)
|
|
minutes, seconds = divmod(seconds, 60)
|
|
hours, minutes = divmod(minutes, 60)
|
|
days, hours = divmod(hours, 24)
|
|
if days:
|
|
ret.append(str(days) + "D")
|
|
if hours or minutes or seconds or usecs:
|
|
ret.append("T")
|
|
if hours:
|
|
ret.append(str(hours) + "H")
|
|
if minutes:
|
|
ret.append(str(minutes) + "M")
|
|
if seconds or usecs:
|
|
if usecs:
|
|
ret.append(("%d.%06d" % (seconds, usecs)).rstrip("0"))
|
|
else:
|
|
ret.append("%d" % seconds)
|
|
ret.append("S")
|
|
if ret:
|
|
return ("-P" if minus else "P") + "".join(ret)
|
|
else:
|
|
# at least one component has to be there.
|
|
return "-P0D" if minus else "P0D"
|
|
else:
|
|
if tdt.days < 0:
|
|
return f"-P{abs(tdt.days // 7)}W"
|
|
return f"P{tdt.days // 7}W"
|
|
|
|
|
|
def xsd_datetime_isoformat(dt: datetime):
|
|
if dt.microsecond == 0:
|
|
no_tz_str = dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
else:
|
|
no_tz_str = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")
|
|
if dt.tzinfo is None:
|
|
return no_tz_str
|
|
else:
|
|
offset_string = dt.strftime("%z")
|
|
if offset_string == "+0000":
|
|
return no_tz_str + "Z"
|
|
first_char = offset_string[0]
|
|
if first_char == "+" or first_char == "-":
|
|
offset_string = offset_string[1:]
|
|
sign = first_char
|
|
else:
|
|
sign = "+"
|
|
tz_part = sign + offset_string[:2] + ":" + offset_string[2:]
|
|
return no_tz_str + tz_part
|
|
|
|
|
|
def parse_xsd_date(date_string: str):
|
|
"""
|
|
XSD Dates have more features than ISO8601 dates, specifically
|
|
XSD allows timezones on dates, that must be stripped off.
|
|
Also, XSD requires dashed separators, while ISO8601 is optional.
|
|
RDFLib test suite has some date strings with times, the times are expected
|
|
to be dropped during parsing.
|
|
"""
|
|
if date_string.endswith("Z") or date_string.endswith("z"):
|
|
date_string = date_string[:-1]
|
|
if date_string.startswith("-"):
|
|
date_string = date_string[1:]
|
|
minus = True
|
|
else:
|
|
minus = False
|
|
if "T" in date_string:
|
|
# RDFLib test suite has some strange date strings, with times.
|
|
# this has the side effect of also dropping the
|
|
# TZ part, that is not wanted anyway for a date.
|
|
date_string = date_string.split("T")[0]
|
|
else:
|
|
has_plus = date_string.rfind("+")
|
|
if has_plus > 0:
|
|
# Drop the +07:00 timezone part
|
|
date_string = date_string[:has_plus]
|
|
else:
|
|
split_parts = date_string.rsplit("-", 1)
|
|
if len(split_parts) > 1 and ":" in split_parts[-1]:
|
|
# Drop the -09:00 timezone part
|
|
date_string = split_parts[0]
|
|
if "-" not in date_string:
|
|
raise ValueError("XSD Date string must contain at least two dashes")
|
|
return parse_date(date_string if not minus else ("-" + date_string))
|
|
|
|
|
|
def parse_xsd_gyear(gyear_string: str):
|
|
"""
|
|
XSD gYear has more features than ISO8601 dates, specifically
|
|
XSD allows timezones on a gYear, that must be stripped off.
|
|
"""
|
|
if gyear_string.endswith("Z") or gyear_string.endswith("z"):
|
|
gyear_string = gyear_string[:-1]
|
|
if gyear_string.startswith("-"):
|
|
gyear_string = gyear_string[1:]
|
|
minus = True
|
|
else:
|
|
minus = False
|
|
has_plus = gyear_string.rfind("+")
|
|
if has_plus > 0:
|
|
# Drop the +07:00 timezone part
|
|
gyear_string = gyear_string[:has_plus]
|
|
else:
|
|
split_parts = gyear_string.rsplit("-", 1)
|
|
if len(split_parts) > 1 and ":" in split_parts[-1]:
|
|
# Drop the -09:00 timezone part
|
|
gyear_string = split_parts[0]
|
|
if len(gyear_string) < 4:
|
|
raise ValueError("gYear string must be at least 4 numerals in length")
|
|
gyear_string = gyear_string.lstrip("0") # strip all leading zeros
|
|
try:
|
|
y = int(gyear_string if not minus else ("-" + gyear_string))
|
|
except ValueError:
|
|
raise ValueError("gYear string must be a valid integer")
|
|
return date(y, 1, 1)
|
|
|
|
|
|
def parse_xsd_gyearmonth(gym_string: str):
|
|
"""
|
|
XSD gYearMonth has more features than ISO8601 dates, specifically
|
|
XSD allows timezones on a gYearMonth, that must be stripped off.
|
|
"""
|
|
if gym_string.endswith("Z") or gym_string.endswith("z"):
|
|
gym_string = gym_string[:-1]
|
|
if gym_string.startswith("-"):
|
|
gym_string = gym_string[1:]
|
|
minus = True
|
|
else:
|
|
minus = False
|
|
has_plus = gym_string.rfind("+")
|
|
if has_plus > 0:
|
|
# Drop the +07:00 timezone part
|
|
gym_string = gym_string[:has_plus]
|
|
else:
|
|
split_parts = gym_string.rsplit("-", 1)
|
|
if len(split_parts) > 1 and ":" in split_parts[-1]:
|
|
# Drop the -09:00 timezone part
|
|
gym_string = split_parts[0]
|
|
year_month_parts = gym_string.split("-", 1)
|
|
if len(year_month_parts) < 2:
|
|
raise ValueError("XSD gYearMonth string must contain one dash")
|
|
|
|
if len(year_month_parts[0]) < 4:
|
|
raise ValueError("gYearMonth Year part must be at least 4 numerals in length")
|
|
elif len(year_month_parts[1]) < 2:
|
|
raise ValueError("gYearMonth Month part must be exactly 2 numerals in length")
|
|
year_string = year_month_parts[0].lstrip("0") # strip all leading zeros
|
|
month_string = year_month_parts[1].lstrip("0") # strip all leading zeros
|
|
try:
|
|
y = int(year_string if not minus else ("-" + year_string))
|
|
except ValueError:
|
|
raise ValueError("gYearMonth Year part must be a valid integer")
|
|
try:
|
|
m = int(month_string)
|
|
except ValueError:
|
|
raise ValueError("gYearMonth Month part must be a valid integer")
|
|
return date(y, m, 1)
|
|
|
|
|
|
# Parse XSD Datetime is the same as ISO8601 Datetime
|
|
# It uses datetime.fromisoformat for python 3.11 and above
|
|
# or isodate.parse_datetime for older versions
|
|
# parse_xsd_datetime = parse_datetime
|
|
|
|
# Parse XSD Time is the same as ISO8601 Time
|
|
# It uses time.fromisoformat for python 3.11 and above
|
|
# or isodate.parse_time for older versions
|
|
# parse_xsd_time = parse_time
|