diff --git a/sdk/src/opendecree/_convert.py b/sdk/src/opendecree/_convert.py index 6cfa126..0b05bda 100644 --- a/sdk/src/opendecree/_convert.py +++ b/sdk/src/opendecree/_convert.py @@ -28,13 +28,17 @@ def _parse_timedelta(s: str) -> timedelta: """Parse a Go-style duration string (e.g., '24h', '30m', '500ms') to timedelta. Supported units: h (hours), m (minutes), s (seconds), ms (milliseconds), - us/µs (microseconds), ns (nanoseconds). Compound forms like '1h30m' work. + us/µs (microseconds), ns (nanoseconds). Compound forms like '1h30m' work, + with an optional leading sign applied to the entire duration. """ if not s: return timedelta() total_seconds = 0.0 - i = 0 + sign = -1 if s[0] == "-" else 1 + i = 1 if s[0] in "+-" else 0 + if i == len(s): + raise ValueError(f"invalid duration: {s!r}") while i < len(s): # Parse number. start = i @@ -66,7 +70,7 @@ def _parse_timedelta(s: str) -> timedelta: case _: raise ValueError(f"unknown duration unit: {unit!r} in {s!r}") - return timedelta(seconds=total_seconds) + return timedelta(seconds=sign * total_seconds) def convert_value(raw: str, target_type: type) -> object: @@ -144,9 +148,11 @@ def typed_value_to_string(tv: object) -> str: if total_s >= 60 and total_s % 60 == 0: return f"{total_s // 60}m" return f"{total_s}s" - # Has sub-second component — use float seconds. - total_float = total_ns / 1_000_000_000 - return f"{total_float}s" + # Keep fractional seconds in decimal notation that the parser accepts. + whole_seconds, nanos = divmod(abs(total_ns), 1_000_000_000) + sign = "-" if total_ns < 0 else "" + fraction = f"{nanos:09d}".rstrip("0") + return f"{sign}{whole_seconds}.{fraction}s" case "integer_value" | "number_value" | "string_value" | "url_value" | "json_value": return str(val) case _: diff --git a/sdk/tests/test_convert.py b/sdk/tests/test_convert.py index 58c288d..abda34c 100644 --- a/sdk/tests/test_convert.py +++ b/sdk/tests/test_convert.py @@ -71,6 +71,27 @@ def test_convert_timedelta_compound(): assert convert_value("1h30m", timedelta) == timedelta(hours=1, minutes=30) +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("-1s", timedelta(seconds=-1)), + ("-1h30m", -timedelta(hours=1, minutes=30)), + ("-500ms", timedelta(milliseconds=-500)), + ("-1us", timedelta(microseconds=-1)), + ("+1h30m", timedelta(hours=1, minutes=30)), + ("-0s", timedelta()), + ], +) +def test_convert_timedelta_signed(raw, expected): + assert convert_value(raw, timedelta) == expected + + +@pytest.mark.parametrize("raw", ["+", "-", "1h-30m", "--1s"]) +def test_convert_timedelta_invalid_sign(raw): + with pytest.raises(TypeMismatchError, match="cannot convert"): + convert_value(raw, timedelta) + + def test_convert_timedelta_invalid(): with pytest.raises(TypeMismatchError, match="cannot convert"): convert_value("not-a-duration", timedelta) @@ -277,6 +298,28 @@ def test_typed_value_to_string_duration_fractional(): assert typed_value_to_string(tv) == "1.5s" +@pytest.mark.parametrize( + ("seconds", "nanos", "expected", "converted"), + [ + (0, 1, "0.000000001s", timedelta()), + (0, 1_000, "0.000001s", timedelta(microseconds=1)), + (0, -1_000, "-0.000001s", timedelta(microseconds=-1)), + (-1, -500_000_000, "-1.5s", timedelta(seconds=-1.5)), + (-60, 0, "-60s", timedelta(minutes=-1)), + (86_400, 1_000, "86400.000001s", timedelta(days=1, microseconds=1)), + ], +) +def test_typed_value_to_string_duration_roundtrip(seconds, nanos, expected, converted): + from google.protobuf.duration_pb2 import Duration + + from opendecree._generated.centralconfig.v1 import types_pb2 + + tv = types_pb2.TypedValue(duration_value=Duration(seconds=seconds, nanos=nanos)) + raw = typed_value_to_string(tv) + assert raw == expected + assert convert_value(raw, timedelta) == converted + + def test_typed_value_to_string_time(): from google.protobuf.timestamp_pb2 import Timestamp