From e4c3bfe2d34e965ab4d1478289abcd8df22e72c4 Mon Sep 17 00:00:00 2001 From: philippewarren Date: Sun, 18 Sep 2022 19:42:36 -0400 Subject: [PATCH] Improve parsing of durations to support floating point hours (decimal, exponentials), hours bigger than 24, and minutes bigger than 60 --- README.md | 4 -- calct/{common.py => _common.py} | 0 calct/_duration_parser.py | 69 +++++++++++++++++++++++++++++++++ calct/duration.py | 9 +++-- calct/parser.py | 2 +- lint.sh | 7 ++++ tests/test_duration_parse.py | 29 ++++++++++++-- 7 files changed, 107 insertions(+), 13 deletions(-) rename calct/{common.py => _common.py} (100%) create mode 100644 calct/_duration_parser.py create mode 100644 lint.sh diff --git a/README.md b/README.md index 6886564..867532c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,3 @@ Easily do calculations on hours and minutes using the command line ## TODO 1. Improve README and documentation 2. Add more tests -3. Custom parsing of durations without using `strptime` to support more formats - - `25h` (bigger than 23h) - - `h61`, `61m` (bigger than 60m) - - `0.1h`(float format for hours) diff --git a/calct/common.py b/calct/_common.py similarity index 100% rename from calct/common.py rename to calct/_common.py diff --git a/calct/_duration_parser.py b/calct/_duration_parser.py new file mode 100644 index 0000000..22c8a8c --- /dev/null +++ b/calct/_duration_parser.py @@ -0,0 +1,69 @@ +# calct: Easily do calculations on hours and minutes using the command line +# Copyright (C) 2022 Philippe Warren +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +import re +from typing import NamedTuple + +from calct._common import Number + + +def _parse_hours(time_str: str) -> Number: + try: + hours_int = int(time_str) + return hours_int + except ValueError: + try: + hours_float = float(time_str) + return hours_float + except ValueError as ex: + raise ValueError(f"Invalid hours: {time_str}") from ex + + +def _parse_minutes(time_str: str) -> int: + try: + minutes_int = int(time_str) + return minutes_int + except ValueError as ex: + raise ValueError(f"Invalid minutes: {time_str}") from ex + + +class Time(NamedTuple): + """A tuple of hours and minutes.""" + + hours: Number + minutes: int + + +DurationMatcher = re.Pattern[str] + + +def compile_matcher(matcher: str) -> DurationMatcher: + float_pattern = r"(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[Ee][+-]?\d+)?" + int_pattern = r"\d+" + matcher_re = ( + "^" + matcher.replace("%H", rf"(?P{float_pattern})").replace("%M", rf"(?P{int_pattern})") + "$" + ) + return re.compile(matcher_re, re.VERBOSE) + + +def parse_duration(time_str: str, pattern: DurationMatcher) -> Time: + matches = pattern.match(time_str) + if matches is None: + raise ValueError(f"Invalid duration: {time_str}") + matches_dict = {"hours": "0", "minutes": "0"} | matches.groupdict() + return Time(hours=_parse_hours(matches_dict["hours"]), minutes=_parse_minutes(matches_dict["minutes"])) diff --git a/calct/duration.py b/calct/duration.py index 8b6d81d..a186dc2 100644 --- a/calct/duration.py +++ b/calct/duration.py @@ -19,14 +19,14 @@ from datetime import timedelta from functools import total_ordering from itertools import chain -from time import strptime -from calct.common import ( +from calct._common import ( CANT_BE_CUSTOM_SEPARATOR, DEFAULT_HOUR_SEPARATOR, DEFAULT_MINUTE_SEPARATOR, Number, ) +from calct._duration_parser import compile_matcher, parse_duration @total_ordering @@ -103,9 +103,10 @@ def get_matchers(cls) -> set[str]: def parse(cls, time_str: str) -> Duration: """Create a Duration from a string.""" for matcher in cls.get_matchers(): + pattern = compile_matcher(matcher) try: - time = strptime(time_str, matcher) - return Duration(hours=time.tm_hour, minutes=time.tm_min) + time = parse_duration(time_str, pattern) + return Duration(hours=time.hours, minutes=time.minutes) except ValueError: pass raise ValueError(f"Invalid time: {time_str}") diff --git a/calct/parser.py b/calct/parser.py index 0280b5d..b8d464f 100644 --- a/calct/parser.py +++ b/calct/parser.py @@ -22,7 +22,7 @@ from operator import add, mul, sub, truediv from typing import Any, Callable, Union, cast -from calct.common import ( +from calct._common import ( DIGITS_STR, FLOAT_CHARS_STR, FLOAT_EXPONENT_STR, diff --git a/lint.sh b/lint.sh new file mode 100644 index 0000000..4ae16b6 --- /dev/null +++ b/lint.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +python -m black --check calct tests +python -m isort --check-only calct tests +python -m mypy -p calct -p tests +python -m flake8 calct tests +python -m pylint calct tests diff --git a/tests/test_duration_parse.py b/tests/test_duration_parse.py index 5ef1202..b441d40 100644 --- a/tests/test_duration_parse.py +++ b/tests/test_duration_parse.py @@ -37,22 +37,43 @@ def test_duration_parse_min(): assert Duration.parse("56m") == Duration(minutes=56) -@pytest.mark.skip("Not implemented") def test_duration_parse_min_bigger_than_59(): assert Duration.parse("86m") == Duration(minutes=86) -@pytest.mark.skip("Not implemented") def test_duration_parse_h_bigger_than_23(): assert Duration.parse("32h12") == Duration(hours=32, minutes=12) -@pytest.mark.skip("Not implemented") -def test_duration_parse_float_hours(): +def test_duration_parse_decimal_hours(): assert Duration.parse("3.5h") == Duration(hours=3.5) assert Duration.parse(".5h12") == Duration(hours=0.5, minutes=12) +def test_duration_parse_empty(): + with pytest.raises(ValueError): + Duration.parse("") + + +def test_duration_parse_exponential_hours(): + assert Duration.parse(".5e2h12") == Duration(hours=0.5e2, minutes=12) + assert Duration.parse("3E2h12") == Duration(hours=3e2, minutes=12) + assert Duration.parse("1000e-1h12") == Duration(hours=1000e-1, minutes=12) + assert Duration.parse("123.34e-3h12") == Duration(hours=123.34e-3, minutes=12) + assert Duration.parse("123.34e+3h12") == Duration(hours=123.34e3, minutes=12) + + +def test_duration_parse_float_minutes(): + with pytest.raises(ValueError): + Duration.parse("3.5m") + with pytest.raises(ValueError): + Duration.parse(".5m") + with pytest.raises(ValueError): + Duration.parse(":.5") + with pytest.raises(ValueError): + Duration.parse("h4e-2") + + def test_duration_parse_pure_number(): with pytest.raises(ValueError): Duration.parse("3")