Functional Weave
Code in Python

time.duration@1.0.0

impl/python.py

3,161 bytes · the Python implementation · view raw

from typing import Optional, Sequence, Tuple

from .time_duration_types import Duration

MAX_DIGITS = 6


def _not_a_duration(text: object) -> ValueError:
    return ValueError('"%s" is not a duration: write it as 1h30m, 90m or 01:30' % (text,))


def _is_digit(ch: str) -> bool:
    # Not str.isdigit(): that accepts superscripts and other scripts' digits.
    return "0" <= ch <= "9"


def _read_number(text: str, s: str, at: int) -> Optional[Tuple[int, int]]:
    """Read a run of digits from ``at``; returns (value, next index), or None if there is none."""
    end = at
    while end < len(s) and _is_digit(s[end]):
        end += 1
    if end == at:
        return None
    if end - at > MAX_DIGITS:
        raise _not_a_duration(text)
    return int(s[at:end]), end


def _skip_spaces(s: str, at: int) -> int:
    while at < len(s) and s[at] in (" ", "\t"):
        at += 1
    return at


def _parse_one(text: str) -> int:
    """One written duration, in minutes."""
    if not isinstance(text, str):
        raise _not_a_duration(text)
    s = text[_skip_spaces(text, 0):].rstrip(" \t")
    colon = s.find(":")
    if colon >= 0:
        hours = _read_number(text, s, 0)
        if hours is None or hours[1] != colon:
            raise _not_a_duration(text)
        minutes = _read_number(text, s, colon + 1)
        if minutes is None or minutes[1] != len(s) or minutes[1] - colon - 1 != 2:
            raise _not_a_duration(text)
        if minutes[0] >= 60:
            raise ValueError('minutes must be under 60 when hours are given, in "%s"' % (text,))
        return hours[0] * 60 + minutes[0]
    at = 0
    hours_value: Optional[int] = None
    minutes_value: Optional[int] = None
    while at < len(s):
        read = _read_number(text, s, at)
        if read is None:
            raise _not_a_duration(text)
        at = _skip_spaces(s, read[1])
        unit = s[at] if at < len(s) else ""
        if unit in ("h", "H") and hours_value is None and minutes_value is None:
            hours_value = read[0]
        elif unit in ("m", "M") and minutes_value is None:
            minutes_value = read[0]
        else:
            raise _not_a_duration(text)
        at = _skip_spaces(s, at + 1)
    if hours_value is None and minutes_value is None:
        raise _not_a_duration(text)
    if hours_value is not None and minutes_value is not None and minutes_value >= 60:
        raise ValueError('minutes must be under 60 when hours are given, in "%s"' % (text,))
    return (hours_value or 0) * 60 + (minutes_value or 0)


def duration(parts: Sequence[str]) -> Duration:
    """Parse each written duration, add them, and give the total in minutes and
    in both written forms. The clock form does not wrap at 24 hours, because a
    total of work is not a time of day.
    """
    total = 0
    for part in parts:
        total += _parse_one(part)
    hours, minutes = divmod(total, 60)
    if hours > 0 and minutes > 0:
        text = "%dh%dm" % (hours, minutes)
    elif hours > 0:
        text = "%dh" % (hours,)
    else:
        text = "%dm" % (minutes,)
    return Duration(minutes=total, text=text, clock="%02d:%02d" % (hours, minutes))