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