Skip to content

Commit

Permalink
Improve parsing of durations to support floating point hours (decimal…
Browse files Browse the repository at this point in the history
…, exponentials), hours bigger than 24, and minutes bigger than 60
  • Loading branch information
philippewarren committed Sep 18, 2022
1 parent c2de5c9 commit e4c3bfe
Show file tree
Hide file tree
Showing 7 changed files with 107 additions and 13 deletions.
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
File renamed without changes.
69 changes: 69 additions & 0 deletions calct/_duration_parser.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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<hours>{float_pattern})").replace("%M", rf"(?P<minutes>{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"]))
9 changes: 5 additions & 4 deletions calct/duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
2 changes: 1 addition & 1 deletion calct/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions lint.sh
Original file line number Diff line number Diff line change
@@ -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
29 changes: 25 additions & 4 deletions tests/test_duration_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

0 comments on commit e4c3bfe

Please sign in to comment.