From eaef1b56df31a7532129a2aa013d5dba6cb58d70 Mon Sep 17 00:00:00 2001 From: GreedyC <62262441+GreedyC@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:58:16 +0300 Subject: [PATCH] Add millisecond units to time expressions --- README.rst | 3 +++ pytimeparse/tests/testtimeparse.py | 30 ++++++++++++++++++++++++++++++ pytimeparse/timeparse.py | 15 ++++++++++++--- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 7e968a5..a75a2bb 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 62586b8..0388e65 100644 --- a/pytimeparse/tests/testtimeparse.py +++ b/pytimeparse/tests/testtimeparse.py @@ -96,6 +96,36 @@ def test_fractional_units_with_signed_seconds(self): self.assertEqual(timeparse.timeparse(prefix + text), sign * expected) + 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)] + for text, expected in cases: + for prefix, sign in [('', 1), ('+', 1), ('-', -1)]: + self.assertAlmostEqual(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 d77256f..e6c4ff9 100644 --- a/pytimeparse/timeparse.py +++ b/pytimeparse/timeparse.py @@ -41,6 +41,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+)?)' @@ -53,14 +54,16 @@ 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), DAYS=OPTSEP(DAYS), HOURS=OPTSEP(HOURS), MINS=OPTSEP(MINS), - SECS=OPT(SECS)), + 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( @@ -88,7 +91,8 @@ ('days', 60 * 60 * 24), ('hours', 60 * 60), ('mins', 60), - ('secs', 1) + ('secs', 1), + ('millis', 1e-3) ]) def _interpret_as_minutes(sval, mdict): @@ -164,6 +168,11 @@ def timeparse(sval, granularity='seconds'): if granularity == 'minutes': mdict = _interpret_as_minutes(sval, mdict) try: + if mdict.get('millis') is not None: + result = sign * sum(MULTIPLIERS[k] * float(v) + for k, v in mdict.items() + if v is not None) + return int(result) if result.is_integer() else result # if all of the fields are integer numbers if all(v.isdigit() for v in list(mdict.values()) if v): return sign * sum([MULTIPLIERS[k] * int(v, 10) for (k, v) in