diff --git a/README.rst b/README.rst index 3ef1acd..4396ee4 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,9 @@ expressions like the following: - ``5hr34m56s`` - ``5 hours, 34 minutes, 56 seconds`` - ``5 hrs, 34 mins, 56 secs`` +- ``250ms`` +- ``1 second, 250 milliseconds`` +- ``1m 500ms`` - ``2 days, 5 hours, 34 minutes, 56 seconds`` - ``1.2 m`` - ``1.2 min`` diff --git a/pytimeparse/tests/testtimeparse.py b/pytimeparse/tests/testtimeparse.py index d9e15b3..a2e20ed 100644 --- a/pytimeparse/tests/testtimeparse.py +++ b/pytimeparse/tests/testtimeparse.py @@ -133,6 +133,37 @@ def test_fractional_units_exact_integer_results(self): timeparse.timeparse(prefix + text), sign * expected, prefix + text ) + def test_milliseconds(self): + for unit in ('ms', 'msec', 'msecs', 'millis', 'millisecond', + 'milliseconds', 'MS'): + for prefix, sign in [('', 1), ('+', 1), ('-', -1)]: + self.assertEqual(timeparse.timeparse(prefix + '250 ' + unit), + sign * 0.25) + + def test_milliseconds_with_other_units(self): + cases = [('1s250ms', 1.25), ('1m 500ms', 60.5), + ('1h, 2m, 3s, 500ms', 3723.5), + ('1.5s 250ms', 1.75), ('0.5m 250ms', 30.25), + ('0.5ms', 0.0005), ('9ms', 0.009), + ('4.1m500ms', 246.5), ('2.3d1ms', 198720.001)] + for text, expected in cases: + for prefix, sign in [('', 1), ('+', 1), ('-', -1)]: + self.assertEqual(timeparse.timeparse(prefix + text), + sign * expected) + + def test_whole_seconds_from_milliseconds(self): + for text, expected in [('0ms', 0), ('1000ms', 1), + ('1s1000ms', 2), ('1.5s500ms', 2)]: + result = timeparse.timeparse(text) + self.assertEqual(result, expected) + self.assertIsInstance(result, int) + + def test_invalid_milliseconds(self): + for text in ('ms', '.ms', '1.2.3ms', '1ms1s', + '1s-250ms', '1ms1ms', '1 microsecond', + '1s,', ',250ms'): + self.assertIsNone(timeparse.timeparse(text), text) + def test_timeparse_1(self): """timeparse test case 1.""" self.assertEqual(timeparse.timeparse("32m"), 1920) diff --git a/pytimeparse/timeparse.py b/pytimeparse/timeparse.py index 93e8001..84a77c0 100644 --- a/pytimeparse/timeparse.py +++ b/pytimeparse/timeparse.py @@ -42,6 +42,7 @@ HOURS = r"(?P[\d.]+)\s*(?:h|hrs?|hours?)" MINS = r"(?P[\d.]+)\s*(?:m|(mins?)|(minutes?))" SECS = r"(?P[\d.]+)\s*(?:s|secs?|seconds?)" +MILLIS = r"(?P[\d.]+)\s*(?:ms|msecs?|millis|milliseconds?)" SEPARATORS = r"[,/]" SECCLOCK = r":(?P\d{2}(?:\.\d+)?)" MINCLOCK = r"(?P\d{1,2}):(?P\d{2}(?:\.\d+)?)" @@ -54,7 +55,7 @@ OPTSEP = lambda x: r"(?:{x}\s*(?:{SEPARATORS}\s*)?)?".format(x=x, SEPARATORS=SEPARATORS) TIMEFORMATS = [ - r"{WEEKS}\s*{DAYS}\s*{HOURS}\s*{MINS}\s*{SECS}".format( + r"{WEEKS}\s*{DAYS}\s*{HOURS}\s*{MINS}\s*{SECS}\s*{MILLIS}".format( # YEARS=OPTSEP(YEARS), # MONTHS=OPTSEP(MONTHS), WEEKS=OPTSEP(WEEKS), @@ -62,6 +63,8 @@ HOURS=OPTSEP(HOURS), MINS=OPTSEP(MINS), SECS=OPT(SECS), + # A seconds field may be followed by a separator, but not on its own. + MILLIS=OPT(r"(?(secs)[,/]?\s*)" + MILLIS), ), r"{MINCLOCK}".format(MINCLOCK=MINCLOCK), r"{WEEKS}\s*{DAYS}\s*{HOURCLOCK}".format( @@ -89,6 +92,7 @@ ("hours", 60 * 60), ("mins", 60), ("secs", 1), + ("millis", Decimal("0.001")), ] ) @@ -173,7 +177,9 @@ def timeparse(sval: str, granularity: str = "seconds") -> Optional[Union[int, fl mdict = _interpret_as_minutes(sval, mdict) try: # if all of the fields are integer numbers - if all(v.isdigit() for v in list(mdict.values()) if v): + if mdict.get("millis") is None and all( + v.isdigit() for v in list(mdict.values()) if v + ): return sign * sum( [ MULTIPLIERS[k] * int(v, 10) @@ -182,7 +188,7 @@ def timeparse(sval: str, granularity: str = "seconds") -> Optional[Union[int, fl ] ) # if SECS is an integer number - elif ( + elif mdict.get("millis") is None and ( "secs" not in mdict or mdict["secs"] is None or mdict["secs"].isdigit() @@ -203,14 +209,15 @@ def timeparse(sval: str, granularity: str = "seconds") -> Optional[Union[int, fl + (int(mdict["secs"], 10) if mdict["secs"] else 0) ) else: - # SECS is a float, we will return a float - return sign * sum( + # Preserve subsecond precision until the final conversion. + result = sign * sum( [ - MULTIPLIERS[k] * float(v) + MULTIPLIERS[k] * Decimal(v) for (k, v) in list(mdict.items()) if v is not None ] ) + return int(result) if result == int(result) else float(result) except (ValueError, InvalidOperation): # Malformed number string (e.g. '1.2.3', '.') — skip to # the next time format pattern per documented behavior.