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