From cf79cc6c84e2c49300e11e8ec53d6a47d47ba73b Mon Sep 17 00:00:00 2001 From: Yuren0312 <168316868+Yuren0312@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:42:19 +0800 Subject: [PATCH] Fix negative timedelta parsing to match timedelta.__str__ semantics (#23) --- pytimeparse/tests/testtimeparse.py | 30 +++++++++++- pytimeparse/timeparse.py | 79 +++++++++++++++--------------- 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/pytimeparse/tests/testtimeparse.py b/pytimeparse/tests/testtimeparse.py index d9e15b3..2888e0c 100644 --- a/pytimeparse/tests/testtimeparse.py +++ b/pytimeparse/tests/testtimeparse.py @@ -240,16 +240,42 @@ def test_timeparse_11(self): # uptime format self.assertEqual(timeparse.timeparse("2 days, 4:13:02"), 187982) self.assertEqual(timeparse.timeparse("+2 days, 4:13:02"), 187982) - self.assertEqual(timeparse.timeparse("-2 days, 4:13:02"), -187982) + # A leading minus negates only the day count, matching the semantics + # of timedelta.__str__ (the HH:MM:SS clock stays non-negative). + self.assertEqual(timeparse.timeparse("-2 days, 4:13:02"), -157618) def test_timeparse_12(self): """timeparse test case 12.""" self.assertAlmostEqual(timeparse.timeparse("2 days, 4:13:02.266"), 187982.266) self.assertAlmostEqual(timeparse.timeparse("+2 days, 4:13:02.266"), 187982.266) self.assertAlmostEqual( - timeparse.timeparse("-2 days, 4:13:02.266"), -187982.266 + timeparse.timeparse("-2 days, 4:13:02.266"), -157617.734 ) + def test_negative_day_clock(self): + """A leading minus applies only to the day count (issue #23).""" + self.assertEqual(timeparse.timeparse("-1 day, 0:00:05"), -86395) + self.assertEqual(timeparse.timeparse("-2 days, 4:13:02"), -157618) + self.assertAlmostEqual( + timeparse.timeparse("-1 day, 0:00:14.513431"), -86385.486569 + ) + + def test_negative_timedelta_roundtrip(self): + """timeparse inverts timedelta.__str__ without drift (issue #23).""" + from datetime import timedelta + + for td in [ + timedelta(days=-1, seconds=5), + timedelta(days=-3, seconds=71218), + timedelta(seconds=-86395), + timedelta(days=-1, microseconds=14513431), + ]: + with self.subTest(td=td): + text = str(td) + self.assertAlmostEqual( + timeparse.timeparse(text), td.total_seconds(), places=6, msg=text + ) + def test_timeparse_13(self): """timeparse test case 13.""" self.assertEqual(timeparse.timeparse("5hr34m56s"), 20096) diff --git a/pytimeparse/timeparse.py b/pytimeparse/timeparse.py index 93e8001..830909b 100644 --- a/pytimeparse/timeparse.py +++ b/pytimeparse/timeparse.py @@ -122,6 +122,31 @@ def _interpret_as_minutes( return mdict +def _sum_fields(mdict: dict[str, Optional[str]], sign: int) -> Union[int, float]: + """ + Sum the seconds represented by parsed fields, applying ``sign``. + + Returns an ``int`` when the result is a whole number of seconds and a + ``float`` otherwise. + """ + values = [(k, v) for k, v in mdict.items() if v is not None] + # if all of the fields are integer numbers + if all(v.isdigit() for _, v in values): + return sign * sum(MULTIPLIERS[k] * int(v, 10) for k, v in values) + secs = mdict.get("secs") + # if SECS is an integer number (or absent) + if secs is None or secs.isdigit(): + # we will return an integer + return sign * ( + # sum exactly so that values such as '4.1m' are + # not truncated one second short by float error + int(sum(MULTIPLIERS[k] * Decimal(v) for k, v in values if k != "secs")) + + (int(secs, 10) if secs else 0) + ) + # SECS is a float, we will return a float + return sign * sum(MULTIPLIERS[k] * float(v) for k, v in values) + + def timeparse(sval: str, granularity: str = "seconds") -> Optional[Union[int, float]]: """ Parse a time expression, returning it as a number of seconds. If @@ -165,52 +190,26 @@ def timeparse(sval: str, granularity: str = "seconds") -> Optional[Union[int, fl assert match is not None sign = -1 if match.groupdict()["sign"] == "-" else 1 sval = match.groupdict()["unsigned"] - for timefmt in COMPILED_TIMEFORMATS: + for idx, timefmt in enumerate(COMPILED_TIMEFORMATS): match = timefmt.match(sval) if match and match.group(0).strip(): mdict = match.groupdict() if granularity == "minutes": 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): - return sign * sum( - [ - MULTIPLIERS[k] * int(v, 10) - for (k, v) in list(mdict.items()) - if v is not None - ] - ) - # if SECS is an integer number - elif ( - "secs" not in mdict - or mdict["secs"] is None - or mdict["secs"].isdigit() - ): - # we will return an integer - return sign * ( - # sum exactly so that values such as '4.1m' are - # not truncated one second short by float error - int( - sum( - [ - MULTIPLIERS[k] * Decimal(v) - for (k, v) in list(mdict.items()) - if k != "secs" and v is not None - ] - ) - ) - + (int(mdict["secs"], 10) if mdict["secs"] else 0) - ) - else: - # SECS is a float, we will return a float - return sign * sum( - [ - MULTIPLIERS[k] * float(v) - for (k, v) in list(mdict.items()) - if v is not None - ] - ) + # `datetime.timedelta.__str__` renders negative durations as + # "-N day[s], HH:MM:SS[.ffffff]": the leading minus applies + # only to the day/week portion while the clock part stays + # non-negative. Pattern 2 (weeks/days followed by an + # HH:MM:SS clock) is that form; DAYCLOCK (pattern 3) is not. + day_clock = idx == 2 and mdict.get("days") is not None + if day_clock and sign == -1: + date_mdict = {k: mdict.get(k) for k in ("weeks", "days")} + clock_mdict = { + k: v for k, v in mdict.items() if k not in ("weeks", "days") + } + return _sum_fields(date_mdict, -1) + _sum_fields(clock_mdict, 1) + return _sum_fields(mdict, sign) except (ValueError, InvalidOperation): # Malformed number string (e.g. '1.2.3', '.') — skip to # the next time format pattern per documented behavior.