Format and Parse Human Durations

Convert seconds into compact day, hour, minute, and second strings and parse those strings back with strict validation.

PythonCli Tooling

Code

snippet
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()