xsd_datetime.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. """
  2. Large parts of this module are taken from the `isodate` package.
  3. https://pypi.org/project/isodate/
  4. Modifications are made to isodate features to allow compatibility with
  5. XSD dates and durations that are not necessarily valid ISO8601 strings.
  6. Copyright (c) 2024, Ashley Sommer, and RDFLib contributors
  7. Copyright (c) 2021, Hugo van Kemenade and contributors
  8. Copyright (c) 2009-2018, Gerhard Weis and contributors
  9. Copyright (c) 2009, Gerhard Weis
  10. All rights reserved.
  11. Redistribution and use in source and binary forms, with or without
  12. modification, are permitted provided that the following conditions are met:
  13. - Redistributions of source code must retain the above copyright
  14. notice, this list of conditions and the following disclaimer.
  15. - Redistributions in binary form must reproduce the above copyright
  16. notice, this list of conditions and the following disclaimer in the
  17. documentation and/or other materials provided with the distribution.
  18. - Neither the name of the <organization> nor the
  19. names of its contributors may be used to endorse or promote products
  20. derived from this software without specific prior written permission.
  21. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  22. ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  23. WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  24. DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
  25. DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
  26. (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
  27. LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
  28. ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  29. (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  30. SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  31. """
  32. from __future__ import annotations
  33. import re
  34. import sys
  35. from datetime import date, datetime, time, timedelta
  36. from decimal import ROUND_FLOOR, Decimal
  37. from typing import List, Tuple, Union, cast
  38. if sys.version_info[:3] < (3, 11, 0):
  39. from isodate import parse_date, parse_datetime, parse_time
  40. else:
  41. # On python 3.11, use the built-in parsers
  42. parse_date = date.fromisoformat
  43. parse_datetime = datetime.fromisoformat
  44. parse_time = time.fromisoformat
  45. def fquotmod(
  46. val: Decimal, low: Union[Decimal, int], high: Union[Decimal, int]
  47. ) -> Tuple[int, Decimal]:
  48. """A divmod function with boundaries."""
  49. # assumes that all the maths is done with Decimals.
  50. # divmod for Decimal uses truncate instead of floor as builtin
  51. # divmod, so we have to do it manually here.
  52. a: Decimal = val - low
  53. b: Union[Decimal, int] = high - low
  54. div: Decimal = (a / b).to_integral(ROUND_FLOOR)
  55. mod: Decimal = a - div * b
  56. # if we were not using Decimal, it would look like this.
  57. # div, mod = divmod(val - low, high - low)
  58. mod += low
  59. return int(div), mod
  60. def max_days_in_month(year: int, month: int) -> int:
  61. """
  62. Determines the number of days of a specific month in a specific year.
  63. """
  64. if month in (1, 3, 5, 7, 8, 10, 12):
  65. return 31
  66. if month in (4, 6, 9, 11):
  67. return 30
  68. if month < 1 or month > 12:
  69. raise ValueError("Month must be in 1..12")
  70. # Month is February
  71. if ((year % 400) == 0) or ((year % 100) != 0) and ((year % 4) == 0):
  72. return 29
  73. return 28
  74. class Duration:
  75. """A class which represents a duration.
  76. The difference to datetime.timedelta is, that this class handles also
  77. differences given in years and months.
  78. A Duration treats differences given in year, months separately from all
  79. other components.
  80. A Duration can be used almost like any timedelta object, however there
  81. are some restrictions:
  82. - It is not really possible to compare Durations, because it is unclear,
  83. whether a duration of 1 year is bigger than 365 days or not.
  84. - Equality is only tested between the two (year, month vs. timedelta)
  85. basic components.
  86. A Duration can also be converted into a datetime object, but this requires
  87. a start date or an end date.
  88. The algorithm to add a duration to a date is defined at
  89. http://www.w3.org/TR/xmlschema-2/#adding-durations-to-dateTimes
  90. """
  91. def __init__(
  92. self,
  93. days: float = 0,
  94. seconds: float = 0,
  95. microseconds: float = 0,
  96. milliseconds: float = 0,
  97. minutes: float = 0,
  98. hours: float = 0,
  99. weeks: float = 0,
  100. months: Union[Decimal, float, int, str] = 0,
  101. years: Union[Decimal, float, int, str] = 0,
  102. ):
  103. """
  104. Initialise this Duration instance with the given parameters.
  105. """
  106. if not isinstance(months, Decimal):
  107. months = Decimal(str(months))
  108. if not isinstance(years, Decimal):
  109. years = Decimal(str(years))
  110. new_years, months = fquotmod(months, 0, 12)
  111. self.months = months
  112. self.years = Decimal(years + new_years)
  113. self.tdelta = timedelta(
  114. days, seconds, microseconds, milliseconds, minutes, hours, weeks
  115. )
  116. if self.years < 0 and self.tdelta.days < 0:
  117. raise ValueError("Duration cannot have negative years and negative days")
  118. def __getstate__(self):
  119. return self.__dict__
  120. def __setstate__(self, state):
  121. self.__dict__.update(state)
  122. def __getattr__(self, name):
  123. """
  124. Provide direct access to attributes of included timedelta instance.
  125. """
  126. return getattr(self.tdelta, name)
  127. def __str__(self):
  128. """
  129. Return a string representation of this duration similar to timedelta.
  130. """
  131. params = []
  132. if self.years:
  133. params.append("%d years" % self.years)
  134. if self.months:
  135. fmt = "%d months"
  136. if self.months <= 1:
  137. fmt = "%d month"
  138. params.append(fmt % self.months)
  139. params.append(str(self.tdelta))
  140. return ", ".join(params)
  141. def __repr__(self):
  142. """
  143. Return a string suitable for repr(x) calls.
  144. """
  145. return "%s.%s(%d, %d, %d, years=%s, months=%s)" % (
  146. self.__class__.__module__,
  147. self.__class__.__name__,
  148. self.tdelta.days,
  149. self.tdelta.seconds,
  150. self.tdelta.microseconds,
  151. str(self.years),
  152. str(self.months),
  153. )
  154. def __hash__(self):
  155. """
  156. Return a hash of this instance so that it can be used in, for
  157. example, dicts and sets.
  158. """
  159. return hash((self.tdelta, self.months, self.years))
  160. def __neg__(self):
  161. """A simple unary minus.
  162. Returns a new Duration instance with all it's negated.
  163. """
  164. negduration = Duration(years=-self.years, months=-self.months)
  165. negduration.tdelta = -self.tdelta
  166. return negduration
  167. def __add__(self, other: Union[Duration, timedelta, date, datetime]):
  168. """
  169. Durations can be added with Duration, timedelta, date and datetime
  170. objects.
  171. """
  172. if isinstance(other, Duration):
  173. newduration = Duration(
  174. years=self.years + other.years, months=self.months + other.months
  175. )
  176. newduration.tdelta = self.tdelta + other.tdelta
  177. return newduration
  178. elif isinstance(other, timedelta):
  179. newduration = Duration(years=self.years, months=self.months)
  180. newduration.tdelta = self.tdelta + other
  181. return newduration
  182. try:
  183. # try anything that looks like a date or datetime
  184. # 'other' has attributes year, month, day
  185. # and relies on 'timedelta + other' being implemented
  186. if not (float(self.years).is_integer() and float(self.months).is_integer()):
  187. raise ValueError(
  188. "fractional years or months not supported for date calculations"
  189. )
  190. newmonth: Decimal = Decimal(other.month) + self.months
  191. carry, newmonth = fquotmod(newmonth, 1, 13)
  192. newyear: int = other.year + int(self.years) + carry
  193. maxdays: int = max_days_in_month(newyear, int(newmonth))
  194. newday: Union[int, float]
  195. if other.day > maxdays:
  196. newday = maxdays
  197. else:
  198. newday = other.day
  199. newdt = other.replace(year=newyear, month=int(newmonth), day=newday)
  200. # does a timedelta + date/datetime
  201. return self.tdelta + newdt
  202. except AttributeError:
  203. # other probably was not a date/datetime compatible object
  204. pass
  205. # we have tried everything .... return a NotImplemented
  206. return NotImplemented
  207. __radd__ = __add__
  208. def __mul__(self, other):
  209. if isinstance(other, int):
  210. newduration = Duration(years=self.years * other, months=self.months * other)
  211. newduration.tdelta = self.tdelta * other
  212. return newduration
  213. return NotImplemented
  214. __rmul__ = __mul__
  215. def __sub__(self, other: Union[Duration, timedelta]):
  216. """
  217. It is possible to subtract Duration and timedelta objects from Duration
  218. objects.
  219. """
  220. if isinstance(other, Duration):
  221. newduration = Duration(
  222. years=self.years - other.years, months=self.months - other.months
  223. )
  224. newduration.tdelta = self.tdelta - other.tdelta
  225. return newduration
  226. try:
  227. # do maths with our timedelta object ....
  228. newduration = Duration(years=self.years, months=self.months)
  229. newduration.tdelta = self.tdelta - other
  230. return newduration
  231. except TypeError:
  232. # looks like timedelta - other is not implemented
  233. pass
  234. return NotImplemented
  235. def __rsub__(self, other: Union[timedelta, date, datetime]):
  236. """
  237. It is possible to subtract Duration objects from date, datetime and
  238. timedelta objects.
  239. """
  240. # TODO: there is some weird behaviour in date - timedelta ...
  241. # if timedelta has seconds or microseconds set, then
  242. # date - timedelta != date + (-timedelta)
  243. # for now we follow this behaviour to avoid surprises when mixing
  244. # timedeltas with Durations, but in case this ever changes in
  245. # the stdlib we can just do:
  246. # return -self + other
  247. # instead of all the current code
  248. if isinstance(other, timedelta):
  249. tmpdur = Duration()
  250. tmpdur.tdelta = other
  251. return tmpdur - self
  252. try:
  253. # check if other behaves like a date/datetime object
  254. # does it have year, month, day and replace?
  255. if not (float(self.years).is_integer() and float(self.months).is_integer()):
  256. raise ValueError(
  257. "fractional years or months not supported for date calculations"
  258. )
  259. newmonth: Decimal = Decimal(other.month) - self.months
  260. carry, newmonth = fquotmod(newmonth, 1, 13)
  261. newyear: int = other.year - int(self.years) + carry
  262. maxdays: int = max_days_in_month(newyear, int(newmonth))
  263. newday: Union[int, float]
  264. if other.day > maxdays:
  265. newday = maxdays
  266. else:
  267. newday = other.day
  268. newdt = other.replace(year=newyear, month=int(newmonth), day=newday)
  269. return newdt - self.tdelta
  270. except AttributeError:
  271. # other probably was not compatible with data/datetime
  272. pass
  273. return NotImplemented
  274. def __eq__(self, other):
  275. """
  276. If the years, month part and the timedelta part are both equal, then
  277. the two Durations are considered equal.
  278. """
  279. if isinstance(other, Duration):
  280. if (self.years * 12 + self.months) == (
  281. other.years * 12 + other.months
  282. ) and self.tdelta == other.tdelta:
  283. return True
  284. return False
  285. # check if other con be compared against timedelta object
  286. # will raise an AssertionError when optimisation is off
  287. if self.years == 0 and self.months == 0:
  288. return self.tdelta == other
  289. return False
  290. def __ne__(self, other):
  291. """
  292. If the years, month part or the timedelta part is not equal, then
  293. the two Durations are considered not equal.
  294. """
  295. if isinstance(other, Duration):
  296. if (self.years * 12 + self.months) != (
  297. other.years * 12 + other.months
  298. ) or self.tdelta != other.tdelta:
  299. return True
  300. return False
  301. # check if other can be compared against timedelta object
  302. # will raise an AssertionError when optimisation is off
  303. if self.years == 0 and self.months == 0:
  304. return self.tdelta != other
  305. return True
  306. def totimedelta(self, start=None, end=None):
  307. """Convert this duration into a timedelta object.
  308. This method requires a start datetime or end datetime, but raises
  309. an exception if both are given.
  310. """
  311. if start is None and end is None:
  312. raise ValueError("start or end required")
  313. if start is not None and end is not None:
  314. raise ValueError("only start or end allowed")
  315. if start is not None:
  316. return (start + self) - start
  317. return end - (end - self)
  318. ISO8601_PERIOD_REGEX = re.compile(
  319. r"^(?P<sign>[+-])?"
  320. r"P(?!\b)"
  321. r"(?P<years>[0-9]+([,.][0-9]+)?Y)?"
  322. r"(?P<months>[0-9]+([,.][0-9]+)?M)?"
  323. r"(?P<weeks>[0-9]+([,.][0-9]+)?W)?"
  324. r"(?P<days>[0-9]+([,.][0-9]+)?D)?"
  325. r"((?P<separator>T)(?P<hours>[0-9]+([,.][0-9]+)?H)?"
  326. r"(?P<minutes>[0-9]+([,.][0-9]+)?M)?"
  327. r"(?P<seconds>[0-9]+([,.][0-9]+)?S)?)?$"
  328. )
  329. # regular expression to parse ISO duration strings.
  330. def parse_xsd_duration(
  331. dur_string: str, as_timedelta_if_possible: bool = True
  332. ) -> Union[Duration, timedelta]:
  333. """Parses an ISO 8601 durations into datetime.timedelta or Duration objects.
  334. If the ISO date string does not contain years or months, a timedelta
  335. instance is returned, else a Duration instance is returned.
  336. The following duration formats are supported:
  337. -`PnnW` duration in weeks
  338. -`PnnYnnMnnDTnnHnnMnnS` complete duration specification
  339. -`PYYYYMMDDThhmmss` basic alternative complete date format
  340. -`PYYYY-MM-DDThh:mm:ss` extended alternative complete date format
  341. -`PYYYYDDDThhmmss` basic alternative ordinal date format
  342. -`PYYYY-DDDThh:mm:ss` extended alternative ordinal date format
  343. The '-' is optional.
  344. Limitations: ISO standard defines some restrictions about where to use
  345. fractional numbers and which component and format combinations are
  346. allowed. This parser implementation ignores all those restrictions and
  347. returns something when it is able to find all necessary components.
  348. In detail:
  349. - it does not check, whether only the last component has fractions.
  350. - it allows weeks specified with all other combinations
  351. The alternative format does not support durations with years, months or
  352. days set to 0.
  353. """
  354. if not isinstance(dur_string, str):
  355. raise TypeError(f"Expecting a string: {dur_string!r}")
  356. match = ISO8601_PERIOD_REGEX.match(dur_string)
  357. if not match:
  358. # try alternative format:
  359. if dur_string.startswith("P"):
  360. durdt = parse_datetime(dur_string[1:])
  361. if as_timedelta_if_possible and durdt.year == 0 and durdt.month == 0:
  362. # FIXME: currently not possible in alternative format
  363. # create timedelta
  364. return timedelta(
  365. days=durdt.day,
  366. seconds=durdt.second,
  367. microseconds=durdt.microsecond,
  368. minutes=durdt.minute,
  369. hours=durdt.hour,
  370. )
  371. else:
  372. # create Duration
  373. return Duration(
  374. days=durdt.day,
  375. seconds=durdt.second,
  376. microseconds=durdt.microsecond,
  377. minutes=durdt.minute,
  378. hours=durdt.hour,
  379. months=durdt.month,
  380. years=durdt.year,
  381. )
  382. raise ValueError("Unable to parse duration string " + dur_string)
  383. groups = match.groupdict()
  384. for key, val in groups.items():
  385. if key not in ("separator", "sign"):
  386. if val is None:
  387. groups[key] = "0n"
  388. # print groups[key]
  389. if key in ("years", "months"):
  390. groups[key] = Decimal(groups[key][:-1].replace(",", "."))
  391. else:
  392. # these values are passed into a timedelta object,
  393. # which works with floats.
  394. groups[key] = float(groups[key][:-1].replace(",", "."))
  395. ret: Union[Duration, timedelta]
  396. if as_timedelta_if_possible and groups["years"] == 0 and groups["months"] == 0:
  397. ret = timedelta(
  398. days=groups["days"], # type: ignore[arg-type]
  399. hours=groups["hours"], # type: ignore[arg-type]
  400. minutes=groups["minutes"], # type: ignore[arg-type]
  401. seconds=groups["seconds"], # type: ignore[arg-type]
  402. weeks=groups["weeks"], # type: ignore[arg-type]
  403. )
  404. if groups["sign"] == "-":
  405. ret = timedelta(0) - ret
  406. else:
  407. ret = Duration(
  408. years=cast(Decimal, groups["years"]),
  409. months=cast(Decimal, groups["months"]),
  410. days=groups["days"], # type: ignore[arg-type]
  411. hours=groups["hours"], # type: ignore[arg-type]
  412. minutes=groups["minutes"], # type: ignore[arg-type]
  413. seconds=groups["seconds"], # type: ignore[arg-type]
  414. weeks=groups["weeks"], # type: ignore[arg-type]
  415. )
  416. if groups["sign"] == "-":
  417. ret = Duration(0) - ret
  418. return ret
  419. def duration_isoformat(tdt: Union[Duration, timedelta], in_weeks: bool = False) -> str:
  420. if not in_weeks:
  421. ret: List[str] = []
  422. minus = False
  423. has_year_or_month = False
  424. if isinstance(tdt, Duration):
  425. if tdt.years == 0 and tdt.months == 0:
  426. pass # don't do anything, we have no year or month
  427. else:
  428. has_year_or_month = True
  429. months = tdt.years * 12 + tdt.months
  430. if months < 0:
  431. minus = True
  432. months = abs(months)
  433. # We can use divmod instead of fquotmod here because its month_count
  434. # not month_index, and we don't have any negative months at this point.
  435. new_years, new_months = divmod(months, 12)
  436. if new_years:
  437. ret.append(str(new_years) + "Y")
  438. if tdt.months:
  439. ret.append(str(new_months) + "M")
  440. tdt = tdt.tdelta
  441. usecs: int = ((tdt.days * 86400) + tdt.seconds) * 1000000 + tdt.microseconds
  442. if usecs < 0:
  443. if minus:
  444. raise ValueError(
  445. "Duration cannot have negative years and negative days"
  446. )
  447. elif has_year_or_month:
  448. raise ValueError(
  449. "Duration cannot have positive years and months but negative days"
  450. )
  451. minus = True
  452. usecs = abs(usecs)
  453. if usecs == 0:
  454. # No delta parts other than years and months
  455. pass
  456. else:
  457. seconds, usecs = divmod(usecs, 1000000)
  458. minutes, seconds = divmod(seconds, 60)
  459. hours, minutes = divmod(minutes, 60)
  460. days, hours = divmod(hours, 24)
  461. if days:
  462. ret.append(str(days) + "D")
  463. if hours or minutes or seconds or usecs:
  464. ret.append("T")
  465. if hours:
  466. ret.append(str(hours) + "H")
  467. if minutes:
  468. ret.append(str(minutes) + "M")
  469. if seconds or usecs:
  470. if usecs:
  471. ret.append(("%d.%06d" % (seconds, usecs)).rstrip("0"))
  472. else:
  473. ret.append("%d" % seconds)
  474. ret.append("S")
  475. if ret:
  476. return ("-P" if minus else "P") + "".join(ret)
  477. else:
  478. # at least one component has to be there.
  479. return "-P0D" if minus else "P0D"
  480. else:
  481. if tdt.days < 0:
  482. return f"-P{abs(tdt.days // 7)}W"
  483. return f"P{tdt.days // 7}W"
  484. def xsd_datetime_isoformat(dt: datetime):
  485. if dt.microsecond == 0:
  486. no_tz_str = dt.strftime("%Y-%m-%dT%H:%M:%S")
  487. else:
  488. no_tz_str = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")
  489. if dt.tzinfo is None:
  490. return no_tz_str
  491. else:
  492. offset_string = dt.strftime("%z")
  493. if offset_string == "+0000":
  494. return no_tz_str + "Z"
  495. first_char = offset_string[0]
  496. if first_char == "+" or first_char == "-":
  497. offset_string = offset_string[1:]
  498. sign = first_char
  499. else:
  500. sign = "+"
  501. tz_part = sign + offset_string[:2] + ":" + offset_string[2:]
  502. return no_tz_str + tz_part
  503. def parse_xsd_date(date_string: str):
  504. """
  505. XSD Dates have more features than ISO8601 dates, specifically
  506. XSD allows timezones on dates, that must be stripped off.
  507. Also, XSD requires dashed separators, while ISO8601 is optional.
  508. RDFLib test suite has some date strings with times, the times are expected
  509. to be dropped during parsing.
  510. """
  511. if date_string.endswith("Z") or date_string.endswith("z"):
  512. date_string = date_string[:-1]
  513. if date_string.startswith("-"):
  514. date_string = date_string[1:]
  515. minus = True
  516. else:
  517. minus = False
  518. if "T" in date_string:
  519. # RDFLib test suite has some strange date strings, with times.
  520. # this has the side effect of also dropping the
  521. # TZ part, that is not wanted anyway for a date.
  522. date_string = date_string.split("T")[0]
  523. else:
  524. has_plus = date_string.rfind("+")
  525. if has_plus > 0:
  526. # Drop the +07:00 timezone part
  527. date_string = date_string[:has_plus]
  528. else:
  529. split_parts = date_string.rsplit("-", 1)
  530. if len(split_parts) > 1 and ":" in split_parts[-1]:
  531. # Drop the -09:00 timezone part
  532. date_string = split_parts[0]
  533. if "-" not in date_string:
  534. raise ValueError("XSD Date string must contain at least two dashes")
  535. return parse_date(date_string if not minus else ("-" + date_string))
  536. # Parse XSD Datetime is the same as ISO8601 Datetime
  537. # It uses datetime.fromisoformat for python 3.11 and above
  538. # or isodate.parse_datetime for older versions
  539. # parse_xsd_datetime = parse_datetime
  540. # Parse XSD Time is the same as ISO8601 Time
  541. # It uses time.fromisoformat for python 3.11 and above
  542. # or isodate.parse_time for older versions
  543. # parse_xsd_time = parse_time