Format and Parse Human Durations
Convert seconds into compact day, hour, minute, and second strings and parse those strings back with strict validation.
Code
from __future__ import annotations
import math
import re
_UNIT_SECONDS = {"d": 86_400, "h": 3_600, "m": 60, "s": 1}
_TOKEN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([dhms])", re.IGNORECASE)
def format_duration(
seconds: float,
*,
max_units: int = 2,
compact: bool = False,
) -> str:
if isinstance(seconds, bool) or not isinstance(seconds, (int, float)):
raise TypeError("seconds must be a number")
if not math.isfinite(seconds) or seconds < 0:
raise ValueError("seconds must be a non-negative finite number")
if not isinstance(max_units, int) or not 1 <= max_units <= 4:
raise ValueError("max_units must be between 1 and 4")
if not isinstance(compact, bool):
raise TypeError("compact must be a boolean")
total = int(round(seconds))
if total == 0:
return "0s"
parts: list[str] = []
for suffix, size in _UNIT_SECONDS.items():
if total >= size:
value, total = divmod(total, size)
parts.append(f"{value}{suffix}")
parts = parts[:max_units]
return "".join(parts) if compact else " ".join(parts)
def parse_duration(text: str) -> float:
if not isinstance(text, str):
raise TypeError("text must be a string")
value = text.strip()
if not value:
raise ValueError("duration must not be empty")
position = 0
total = 0.0
seen = False
while position < len(value):
match = _TOKEN.match(value, position)
if match is None:
raise ValueError(
f"invalid duration at position {position}: "
f"{value[position:]!r}"
)
amount = float(match.group(1))
unit = match.group(2).lower()
total += amount * _UNIT_SECONDS[unit]
position = match.end()
seen = True
if not seen:
raise ValueError("duration must contain at least one unit")
return total
def main() -> None:
for seconds in (0, 45, 3661, 90061, 172800):
print(seconds, "->", format_duration(seconds))
print(format_duration(3661, compact=True))
print(parse_duration("1d 2h 30m"))
print(parse_duration("1h30m"))
try:
parse_duration("1h 30")
except ValueError as error:
print(error)
try:
format_duration(-1)
except ValueError as error:
print(error)
if __name__ == "__main__":
main()Report a problem with this exampleOpens a pre-filled issue on GitHub.