import datetime
import operator
import re
from collections import UserString
from typing import Any, Callable, Dict, NamedTuple, Optional, Union
from xsdata.utils.dates import (
calculate_offset,
calculate_timezone,
format_date,
format_offset,
format_time,
parse_date_args,
validate_date,
validate_time,
)
xml_duration_re = re.compile(
r"^([-]?)P"
r"(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?"
r"(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(.\d+)?)S)?)?$"
)
DS_YEAR = 31556926.0
DS_MONTH = 2629743
DS_DAY = 86400
DS_HOUR = 3600
DS_MINUTE = 60
DS_FRACTIONAL_SECOND = 0.000000001
DS_OFFSET = -60
class DateFormat:
DATE = "%Y-%m-%d%z"
TIME = "%H:%M:%S%z"
DATE_TIME = "%Y-%m-%dT%H:%M:%S%z"
G_DAY = "---%d%z"
G_MONTH = "--%m%z"
G_MONTH_DAY = "--%m-%d%z"
G_YEAR = "%Y%z"
G_YEAR_MONTH = "%Y-%m%z"
[docs]
class XmlDate(NamedTuple):
"""
Concrete xs:date builtin type.
Represents iso 8601 date format [-]CCYY-MM-DD[Z|(+|-)hh:mm] with
rich comparisons and hashing.
:param year: Any signed integer, eg (0, -535, 2020)
:param month: Unsigned integer between 1-12
:param day: Unsigned integer between 1-31
:param offset: Signed integer representing timezone offset in
minutes
"""
year: int
month: int
day: int
offset: Optional[int] = None
[docs]
def replace(
self,
year: Optional[int] = None,
month: Optional[int] = None,
day: Optional[int] = None,
offset: Optional[int] = True,
) -> "XmlDate":
"""Return a new instance replacing the specified fields with new
values."""
if year is None:
year = self.year
if month is None:
month = self.month
if day is None:
day = self.day
if offset is True:
offset = self.offset
return type(self)(year, month, day, offset)
[docs]
@classmethod
def from_string(cls, string: str) -> "XmlDate":
"""Initialize from string with format ``%Y-%m-%dT%z``"""
return cls(*parse_date_args(string, DateFormat.DATE))
[docs]
@classmethod
def from_date(cls, obj: datetime.date) -> "XmlDate":
"""
Initialize from :class:`datetime.date` instance.
.. warning::
date instances don't have timezone information!
"""
return cls(obj.year, obj.month, obj.day)
[docs]
@classmethod
def from_datetime(cls, obj: datetime.datetime) -> "XmlDate":
"""Initialize from :class:`datetime.datetime` instance."""
return cls(obj.year, obj.month, obj.day, calculate_offset(obj))
[docs]
@classmethod
def today(cls) -> "XmlDate":
"""Initialize from datetime.date.today()"""
return cls.from_date(datetime.date.today())
[docs]
def to_date(self) -> datetime.date:
"""Return a :class:`datetime.date` instance."""
return datetime.date(self.year, self.month, self.day)
[docs]
def to_datetime(self) -> datetime.datetime:
"""Return a :class:`datetime.datetime` instance."""
tz_info = calculate_timezone(self.offset)
return datetime.datetime(self.year, self.month, self.day, tzinfo=tz_info)
[docs]
def __str__(self) -> str:
"""
Return the date formatted according to ISO 8601 for xml.
Examples:
- 2001-10-26
- 2001-10-26+02:00
- 2001-10-26Z
"""
return format_date(self.year, self.month, self.day) + format_offset(self.offset)
def __repr__(self) -> str:
args = [self.year, self.month, self.day, self.offset]
if args[-1] is None:
del args[-1]
return f"{self.__class__.__qualname__}({', '.join(map(str, args))})"
[docs]
class XmlDateTime(NamedTuple):
"""
Concrete xs:dateTime builtin type.
Represents iso 8601 date time format [-]CCYY-MM-DDThh
:mm: ss[Z|(+|-)hh:mm] with rich comparisons and hashing.
:param year: Any signed integer, eg (0, -535, 2020)
:param month: Unsigned integer between 1-12
:param day: Unsigned integer between 1-31
:param hour: Unsigned integer between 0-24
:param minute: Unsigned integer between 0-59
:param second: Unsigned integer between 0-59
:param fractional_second: Unsigned integer between 0-999999999
:param offset: Signed integer representing timezone offset in
minutes
"""
year: int
month: int
day: int
hour: int
minute: int
second: int
fractional_second: int = 0
offset: Optional[int] = None
@property
def microsecond(self) -> int:
return self.fractional_second // 1000
@property
def duration(self) -> float:
if self.year < 0:
negative = True
year = -self.year
else:
negative = False
year = self.year
total = (
year * DS_YEAR
+ self.month * DS_MONTH
+ self.day * DS_DAY
+ self.hour * DS_HOUR
+ self.minute * DS_MINUTE
+ self.second
+ self.fractional_second * DS_FRACTIONAL_SECOND
+ (self.offset or 0) * DS_OFFSET
)
return -total if negative else total
[docs]
@classmethod
def from_string(cls, string: str) -> "XmlDateTime":
"""Initialize from string with format ``%Y-%m-%dT%H:%M:%S%z``"""
(
year,
month,
day,
hour,
minute,
second,
fractional_second,
offset,
) = parse_date_args(string, DateFormat.DATE_TIME)
validate_date(year, month, day)
validate_time(hour, minute, second, fractional_second)
return cls(year, month, day, hour, minute, second, fractional_second, offset)
[docs]
@classmethod
def from_datetime(cls, obj: datetime.datetime) -> "XmlDateTime":
"""Initialize from :class:`datetime.datetime` instance."""
return cls(
obj.year,
obj.month,
obj.day,
obj.hour,
obj.minute,
obj.second,
obj.microsecond * 1000,
calculate_offset(obj),
)
[docs]
@classmethod
def now(cls, tz: Optional[datetime.timezone] = None) -> "XmlDateTime":
"""Initialize from datetime.datetime.now()"""
return cls.from_datetime(datetime.datetime.now(tz=tz))
[docs]
@classmethod
def utcnow(cls) -> "XmlDateTime":
"""Initialize from datetime.now(timezone.utc)"""
return cls.from_datetime(datetime.datetime.now(datetime.timezone.utc))
[docs]
def to_datetime(self) -> datetime.datetime:
"""Return a :class:`datetime.datetime` instance."""
return datetime.datetime(
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
self.microsecond,
tzinfo=calculate_timezone(self.offset),
)
[docs]
def replace(
self,
year: Optional[int] = None,
month: Optional[int] = None,
day: Optional[int] = None,
hour: Optional[int] = None,
minute: Optional[int] = None,
second: Optional[int] = None,
fractional_second: Optional[int] = None,
offset: Optional[int] = True,
) -> "XmlDateTime":
"""Return a new instance replacing the specified fields with new
values."""
if year is None:
year = self.year
if month is None:
month = self.month
if day is None:
day = self.day
if hour is None:
hour = self.hour
if minute is None:
minute = self.minute
if second is None:
second = self.second
if fractional_second is None:
fractional_second = self.fractional_second
if offset is True:
offset = self.offset
return type(self)(
year, month, day, hour, minute, second, fractional_second, offset
)
[docs]
def __str__(self) -> str:
"""
Return the datetime formatted according to ISO 8601 for xml.
Examples:
- 2001-10-26T21:32:52
- 2001-10-26T21:32:52+02:00
- 2001-10-26T19:32:52Z
- 2001-10-26T19:32:52.126789
- 2001-10-26T21:32:52.126
- -2001-10-26T21:32:52.126Z
"""
return "{}T{}{}".format(
format_date(self.year, self.month, self.day),
format_time(self.hour, self.minute, self.second, self.fractional_second),
format_offset(self.offset),
)
def __repr__(self) -> str:
args = tuple(self)
if args[-1] is None:
args = args[:-1]
if args[-1] == 0:
args = args[:-1]
return f"{self.__class__.__qualname__}({', '.join(map(str, args))})"
[docs]
def __eq__(self, other: Any) -> bool:
return cmp(self, other, operator.eq)
[docs]
def __ne__(self, other: Any) -> bool:
return cmp(self, other, operator.ne)
[docs]
def __lt__(self, other: Any) -> bool:
return cmp(self, other, operator.lt)
[docs]
def __le__(self, other: Any) -> bool:
return cmp(self, other, operator.le)
[docs]
def __gt__(self, other: Any) -> bool:
return cmp(self, other, operator.gt)
[docs]
def __ge__(self, other: Any) -> bool:
return cmp(self, other, operator.ge)
[docs]
class XmlTime(NamedTuple):
"""
Concrete xs:time builtin type.
Represents iso 8601 time format hh
:mm: ss[Z|(+|-)hh:mm] with rich comparisons and hashing.
:param hour: Unsigned integer between 0-24
:param minute: Unsigned integer between 0-59
:param second: Unsigned integer between 0-59
:param fractional_second: Unsigned integer between 0-999999999
:param offset: Signed integer representing timezone offset in
minutes
"""
hour: int
minute: int
second: int
fractional_second: int = 0
offset: Optional[int] = None
@property
def microsecond(self) -> int:
return self.fractional_second // 1000
@property
def duration(self) -> float:
return (
self.hour * DS_HOUR
+ self.minute * DS_MINUTE
+ self.second
+ self.fractional_second * DS_FRACTIONAL_SECOND
+ (self.offset or 0) * DS_OFFSET
)
[docs]
def replace(
self,
hour: Optional[int] = None,
minute: Optional[int] = None,
second: Optional[int] = None,
fractional_second: Optional[int] = None,
offset: Optional[int] = True,
) -> "XmlTime":
"""Return a new instance replacing the specified fields with new
values."""
if hour is None:
hour = self.hour
if minute is None:
minute = self.minute
if second is None:
second = self.second
if fractional_second is None:
fractional_second = self.fractional_second
if offset is True:
offset = self.offset
return type(self)(hour, minute, second, fractional_second, offset)
[docs]
@classmethod
def from_string(cls, string: str) -> "XmlTime":
"""Initialize from string format ``%H:%M:%S%z``"""
hour, minute, second, fractional_second, offset = parse_date_args(
string, DateFormat.TIME
)
validate_time(hour, minute, second, fractional_second)
return cls(hour, minute, second, fractional_second, offset)
[docs]
@classmethod
def from_time(cls, obj: datetime.time) -> "XmlTime":
"""Initialize from :class:`datetime.time` instance."""
return cls(
obj.hour,
obj.minute,
obj.second,
obj.microsecond * 1000,
calculate_offset(obj),
)
[docs]
@classmethod
def now(cls, tz: Optional[datetime.timezone] = None) -> "XmlTime":
"""Initialize from datetime.datetime.now()"""
return cls.from_time(datetime.datetime.now(tz=tz).time())
[docs]
@classmethod
def utcnow(cls) -> "XmlTime":
"""Initialize from datetime.now(timezone.utc)"""
return cls.from_time(datetime.datetime.now(datetime.timezone.utc).time())
[docs]
def to_time(self) -> datetime.time:
"""Return a :class:`datetime.time` instance."""
return datetime.time(
self.hour,
self.minute,
self.second,
self.microsecond,
tzinfo=calculate_timezone(self.offset),
)
[docs]
def __str__(self) -> str:
"""
Return the time formatted according to ISO 8601 for xml.
Examples:
- 21:32:52
- 21:32:52+02:00,
- 19:32:52Z
- 21:32:52.126789
- 21:32:52.126Z
"""
return "{}{}".format(
format_time(self.hour, self.minute, self.second, self.fractional_second),
format_offset(self.offset),
)
def __repr__(self) -> str:
args = list(self)
if args[-1] is None:
del args[-1]
return f"{self.__class__.__qualname__}({', '.join(map(str, args))})"
[docs]
def __eq__(self, other: Any) -> bool:
return cmp(self, other, operator.eq)
[docs]
def __ne__(self, other: Any) -> bool:
return cmp(self, other, operator.ne)
[docs]
def __lt__(self, other: Any) -> bool:
return cmp(self, other, operator.lt)
[docs]
def __le__(self, other: Any) -> bool:
return cmp(self, other, operator.le)
[docs]
def __gt__(self, other: Any) -> bool:
return cmp(self, other, operator.gt)
[docs]
def __ge__(self, other: Any) -> bool:
return cmp(self, other, operator.ge)
DurationType = Union[XmlTime, XmlDateTime]
def cmp(a: DurationType, b: DurationType, op: Callable) -> bool:
if isinstance(b, a.__class__):
return op(a.duration, b.duration)
return NotImplemented
class TimeInterval(NamedTuple):
negative: bool
years: Optional[int]
months: Optional[int]
days: Optional[int]
hours: Optional[int]
minutes: Optional[int]
seconds: Optional[float]
[docs]
class XmlDuration(UserString):
"""
Concrete xs:duration builtin type.
Represents iso 8601 duration format PnYnMnDTnHnMnS
with rich comparisons and hashing.
Format PnYnMnDTnHnMnS:
- **P**: literal value that starts the expression
- **nY**: the number of years followed by a literal Y
- **nM**: the number of months followed by a literal M
- **nD**: the number of days followed by a literal D
- **T**: literal value that separates date and time parts
- **nH**: the number of hours followed by a literal H
- **nM**: the number of minutes followed by a literal M
- **nS**: the number of seconds followed by a literal S
:param value: String representation of a xs:duration, eg **P2Y6M5DT12H**
"""
def __init__(self, value: str) -> None:
super().__init__(value)
self._interval = self._parse_interval(value)
@property
def years(self) -> Optional[int]:
"""Number of years in the interval."""
return self._interval.years
@property
def months(self) -> Optional[int]:
"""Number of months in the interval."""
return self._interval.months
@property
def days(self) -> Optional[int]:
"""Number of days in the interval."""
return self._interval.days
@property
def hours(self) -> Optional[int]:
"""Number of hours in the interval."""
return self._interval.hours
@property
def minutes(self) -> Optional[int]:
"""Number of minutes in the interval."""
return self._interval.minutes
@property
def seconds(self) -> Optional[float]:
"""Number of seconds in the interval."""
return self._interval.seconds
@property
def negative(self) -> bool:
"""Negative flag of the interval."""
return self._interval.negative
@classmethod
def _parse_interval(cls, value: str) -> TimeInterval:
if not isinstance(value, str):
raise ValueError("Value must be string")
if len(value) < 3 or value.endswith("T"):
raise ValueError(f"Invalid format '{value}'")
match = xml_duration_re.match(value)
if not match:
raise ValueError(f"Invalid format '{value}'")
sign, years, months, days, hours, minutes, seconds, _ = match.groups()
return TimeInterval(
negative=sign == "-",
years=int(years) if years else None,
months=int(months) if months else None,
days=int(days) if days else None,
hours=int(hours) if hours else None,
minutes=int(minutes) if minutes else None,
seconds=float(seconds) if seconds else None,
)
def asdict(self) -> Dict:
return self._interval._asdict()
def __repr__(self) -> str:
return f'{self.__class__.__qualname__}("{self.data}")'
class TimePeriod(NamedTuple):
year: Optional[int]
month: Optional[int]
day: Optional[int]
offset: Optional[int]
[docs]
class XmlPeriod(UserString):
"""
Concrete xs:gYear/Month/Day builtin type.
Represents iso 8601 period formats with rich comparisons and hashing.
Formats:
- xs:gDay: **---%d%z**
- xs:gMonth: **--%m%z**
- xs:gYear: **%Y%z**
- xs:gMonthDay: **--%m-%d%z**
- xs:gYearMonth: **%Y-%m%z**
:param value: String representation of a xs:period, eg **--11-01Z**
"""
def __init__(self, value: str) -> None:
value = value.strip()
super().__init__(value)
self._period = self._parse_period(value)
@property
def year(self) -> Optional[int]:
"""Period year."""
return self._period.year
@property
def month(self) -> Optional[int]:
"""Period month."""
return self._period.month
@property
def day(self) -> Optional[int]:
"""Period day."""
return self._period.day
@property
def offset(self) -> Optional[int]:
"""Period timezone offset in minutes."""
return self._period.offset
@classmethod
def _parse_period(cls, value: str) -> TimePeriod:
year = month = day = offset = None
if value.startswith("---"):
day, offset = parse_date_args(value, DateFormat.G_DAY)
elif value.startswith("--"):
# Bogus format --MM--, --05---05:00
if value[4:6] == "--":
value = value[:4] + value[6:]
if len(value) in (4, 5, 10): # fixed lengths with/out timezone
month, offset = parse_date_args(value, DateFormat.G_MONTH)
else:
month, day, offset = parse_date_args(value, DateFormat.G_MONTH_DAY)
else:
end = len(value)
if value.find(":") > -1: # offset
end -= 6
if value[:end].rfind("-") > 3: # Minimum position for month sep
year, month, offset = parse_date_args(value, DateFormat.G_YEAR_MONTH)
else:
year, offset = parse_date_args(value, DateFormat.G_YEAR)
validate_date(0, month or 1, day or 1)
return TimePeriod(year=year, month=month, day=day, offset=offset)
[docs]
def as_dict(self) -> Dict:
"""Return date units as dict."""
return self._period._asdict()
def __repr__(self) -> str:
return f'{self.__class__.__qualname__}("{self.data}")'
[docs]
def __eq__(self, other: Any) -> bool:
if isinstance(other, XmlPeriod):
return self._period == other._period
return NotImplemented
[docs]
class XmlHexBinary(bytes):
"""
Subclass bytes to infer base16 format.
This type can be used with xs:anyType fields that don't have a
format property to specify the target output format.
"""
[docs]
class XmlBase64Binary(bytes):
"""
Subclass bytes to infer base64 format.
This type can be used with xs:anyType fields that don't have a
format property to specify the target output format.
"""