diff --git a/ops/_private/timeconv.py b/ops/_private/timeconv.py index 7b4b0ed19..ceb67f32a 100644 --- a/ops/_private/timeconv.py +++ b/ops/_private/timeconv.py @@ -16,6 +16,7 @@ import datetime import re +from typing import Union # Matches yyyy-mm-ddTHH:MM:SS(.sss)ZZZ _TIMESTAMP_RE = re.compile( @@ -27,18 +28,6 @@ # Matches n.n (allow U+00B5 micro symbol as well as U+03BC Greek letter mu) _DURATION_RE = re.compile(r'([0-9.]+)([a-zµμ]+)') -# Mapping of unit to float seconds -_DURATION_UNITS = { - 'ns': 0.000_000_001, - 'us': 0.000_001, - 'µs': 0.000_001, # U+00B5 = micro symbol - 'μs': 0.000_001, # U+03BC = Greek letter mu - 'ms': 0.001, - 's': 1, - 'm': 60, - 'h': 60 * 60, -} - def parse_rfc3339(s: str) -> datetime.datetime: """Parse an RFC3339 timestamp. @@ -96,16 +85,45 @@ def parse_duration(s: str) -> datetime.timedelta: if matches[0].start() != 0 or matches[-1].end() != len(s): raise ValueError('invalid duration: extra input at start or end') - seconds = 0 + hours, minutes, seconds, milliseconds, microseconds = 0, 0, 0, 0, 0 for match in matches: number, unit = match.groups() - if unit not in _DURATION_UNITS: + if unit == 'ns': + microseconds += _duration_number(number) / 1000 + elif unit in ('us', 'µs', 'μs'): # U+00B5 (micro symbol), U+03BC (Greek letter mu) + microseconds += _duration_number(number) + elif unit == 'ms': + milliseconds += _duration_number(number) + elif unit == 's': + seconds += _duration_number(number) + elif unit == 'm': + minutes += _duration_number(number) + elif unit == 'h': + hours += _duration_number(number) + else: raise ValueError(f'invalid duration: invalid unit {unit!r}') - try: - seconds += float(number) * _DURATION_UNITS[unit] - except ValueError: - # Same exception type, but a slightly more specific error message - raise ValueError(f'invalid duration: {number!r} is not a valid float') from None - duration = datetime.timedelta(seconds=seconds) + duration = datetime.timedelta( + hours=hours, + minutes=minutes, + seconds=seconds, + milliseconds=milliseconds, + microseconds=microseconds, + ) + return -duration if negative else duration + + +def _duration_number(s: str) -> Union[int, float]: + """Try converting s to int; if that fails, try float; otherwise raise ValueError. + + This is to preserve precision where possible. + """ + try: + try: + return int(s) + except ValueError: + return float(s) + except ValueError: + # Same exception type, but a slightly more specific error message + raise ValueError(f'invalid duration: {s!r} is not a valid float') from None diff --git a/test/test_private.py b/test/test_private.py index 89bf28d2e..e415d19da 100644 --- a/test/test_private.py +++ b/test/test_private.py @@ -147,6 +147,9 @@ def test_parse_duration(self): ('0.100000000000000000000h', datetime.timedelta(seconds=6 * 60)), # This value tests the first overflow check in leadingFraction. ('0.830103483285477580700h', datetime.timedelta(seconds=49 * 60 + 48.372_539_827)), + + # Test precision handling + ('7200000h1us', datetime.timedelta(hours=7_200_000, microseconds=1)) ] for input, expected in cases: