Skip to content

Instantly share code, notes, and snippets.

@benkehoe
Last active December 22, 2022 17:54
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save benkehoe/5b03c308b038b29e42106f602e554010 to your computer and use it in GitHub Desktop.
Save benkehoe/5b03c308b038b29e42106f602e554010 to your computer and use it in GitHub Desktop.
IS8601 functions for datetime.timedelta
# 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
@lsloan
Copy link

lsloan commented Dec 21, 2022

It's strange that timedelta signifies negative values by letting the value of days be negative. I would expect it to have a negative property with a Boolean value. Otherwise, it seems like the value of days in the delta is negative, but the value of seconds and microseconds may be independently positive.

@benkehoe
Copy link
Author

Seconds and microseconds are always positive (or rather, non-negative). The fields for timedelta(seconds=-1) will be days=-1, seconds=86399, and microseconds=0. The way timedelta is implemented provides for:

  1. Unique representation of every value, versus an implementation that let all fields be independently positive or negative.
  2. Slightly simpler normalization than a representation where negative values have all fields negative.
  3. Arithmetic operations can be performed directly on the fields irrespective of their values (followed by a normalization), versus an implementation where negative was indicated by a boolean and all arithmetic operations needed to be surrounded by an if statement.

It's not the only possible implementation, and potentially not even the best one, but it is quite sensible overall.

The check I have for negative values is td.days < 0 but it could equally be td < timedelta(0) (which is probably more proper).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment