-
-
Save lsloan/709664753e314cdfdaeb9d8ae9a5e5a0 to your computer and use it in GitHub Desktop.
IS8601 functions for datetime.timedelta
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# MIT No Attribution | |
# | |
# Copyright 2022 Ben Kehoe | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining a copy of this | |
# software and associated documentation files (the "Software"), to deal in the Software | |
# without restriction, including without limitation the rights to use, copy, modify, | |
# merge, publish, distribute, sublicense, and/or sell copies of the Software, and to | |
# permit persons to whom the Software is furnished to do so. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
# PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE | |
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
# This is intended to be lightweight and copied into other projects | |
# rather than used as a separate library added as a dependency from PyPI. | |
# For full ISO8601 support, including durations that cannot be represented by | |
# datetime.timedelta, use the isodate package: https://github.com/gweis/isodate | |
"""fromisoformat() and isoformat() functions for datetime.timedelta""" | |
__all__ = ["fromisoformat", "isoformat"] | |
from datetime import timedelta | |
import re | |
_NUMBER_PATTERN = r"([0-9]+)(.[0-9]{1,6})?" | |
PATTERN = re.compile( | |
r"P" + | |
# parse years and months for better error messages | |
r"((?P<years>" + _NUMBER_PATTERN + ")Y)?" + | |
r"((?P<months>" + _NUMBER_PATTERN + ")M)?" + | |
r"((?P<days>" + _NUMBER_PATTERN + ")D)?" + | |
r"(" + | |
r"T" + | |
r"((?P<hours>" + _NUMBER_PATTERN + ")H)?" + | |
r"((?P<minutes>" + _NUMBER_PATTERN + ")M)?" + | |
r"((?P<seconds>" + _NUMBER_PATTERN + ")S)?" + | |
r")?" | |
) | |
# Weeks are their own pattern | |
WEEK_PATTERN = re.compile(r"P(?P<weeks>[0-9]+)W") | |
def fromisoformat(s: str) -> timedelta: # pylint: disable=C0103 | |
"""Returns a timedelta for one of two ISO8601 duration formats: | |
PnDTnHnMnS | |
PnW | |
timedelta does not support years or months. | |
Additionally, timedelta's normalized representation may cause loss of | |
fidelity. In ISO8601, PT36H is distinct from P1DT12H and would result | |
in different datetime when added to a datetime just before a DST boundary, | |
but fromisoformat() will return the same timedelta value for both, | |
representing 1 day and 43200 seconds. | |
In keeping with the datetime module, support for formatting only extends | |
as far as roundtripping from isoformat(). | |
""" | |
# Must have at least one field | |
if len(s) < 3: | |
raise ValueError("Not a valid or supported duration") | |
match = PATTERN.fullmatch(s) | |
if not match: | |
match = WEEK_PATTERN.fullmatch(s) # Assume week format is less likely | |
if match: | |
return timedelta(weeks=int(match.group("weeks"))) | |
raise ValueError("Not a valid or supported duration") | |
if match.group("years") or match.group("months"): | |
raise ValueError("timedelta does not support years or months") | |
params = {} | |
last_field = True | |
for key in ["seconds", "minutes", "hours", "days"]: | |
value = match.group(key) | |
if value: | |
if not last_field and "." in value: | |
raise ValueError("Fractions are only allowed in the last field") | |
params[key] = float(value) | |
last_field = False | |
return timedelta(**params) | |
def isoformat(td: timedelta) -> str: # pylint: disable=C0103 | |
"""Returns an ISO8601-formatted representation of the timedelta. | |
Negative values are not supported by ISO8601. | |
timedelta(0) is represented as 'PT0S'. | |
If microseconds is not zero, fractional seconds are included at | |
6 digits of precision using a period as the decimal separator, | |
like other datetime objects. | |
""" | |
if td.days < 0: # other timedelta fields can't be negative, just check days | |
raise ValueError("ISO8601 does not support negative durations") | |
if not td: | |
return "PT0S" | |
s = "P" | |
if td.days: | |
s += str(td.days) + "D" | |
if td.seconds or td.microseconds: | |
s += "T" | |
seconds = td.seconds | |
hours, seconds = divmod(seconds, 3600) | |
if hours: | |
s += str(hours) + "H" | |
minutes, seconds = divmod(seconds, 60) | |
if minutes: | |
s += str(minutes) + "M" | |
if seconds or td.microseconds: | |
s += str(seconds) | |
if td.microseconds: | |
s += ".{:06d}".format(td.microseconds) # pylint: disable=C0209 | |
if seconds or td.microseconds: | |
s += "S" | |
return s |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment