Skip to content

Commit 496135d

Browse files
zeevdrclaude
andcommitted
feat(convert): add datetime, dict/list, and URL type support
Extend convert_value to handle three previously unsupported proto TypedValue variants: - datetime via datetime.fromisoformat (handles RFC 3339 Z suffix, requires Python 3.11+) - dict/list via json.loads with type-mismatch guard - URL as a str alias exported from the top-level package Add tests for all new types including invalid-input and wrong-type error paths. Update the unsupported-type test to use bytes instead of list. Co-Authored-By: Claude <noreply@anthropic.com> Closes #50
1 parent 8b51cec commit 496135d

3 files changed

Lines changed: 73 additions & 6 deletions

File tree

sdk/src/opendecree/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
TypeMismatchError,
2424
UnavailableError,
2525
)
26+
from opendecree._convert import URL
2627
from opendecree.types import Change, ConfigValue, FieldUpdate, ServerVersion
2728
from opendecree.watcher import ConfigWatcher, WatchedField
2829

@@ -48,6 +49,7 @@
4849
"RetryConfig",
4950
"ServerVersion",
5051
"TypeMismatchError",
52+
"URL",
5153
"UnavailableError",
5254
"WatchedField",
5355
"__version__",

sdk/src/opendecree/_convert.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22
33
The server stores all values internally as strings. The SDK converts between
44
the proto TypedValue representation and Python types (str, int, float, bool,
5-
timedelta) at the boundary.
5+
datetime, timedelta, dict, list) at the boundary.
66
"""
77

88
from __future__ import annotations
99

10-
from datetime import timedelta
10+
import json
11+
from datetime import datetime, timedelta
1112

1213
from opendecree.errors import TypeMismatchError
1314

15+
# Type alias for url-typed fields — semantically distinct from plain str.
16+
URL = str
17+
1418

1519
def _parse_timedelta(s: str) -> timedelta:
1620
"""Parse a Go-style duration string (e.g., '24h', '30m', '500ms') to timedelta.
@@ -60,7 +64,8 @@ def _parse_timedelta(s: str) -> timedelta:
6064
def convert_value(raw: str, target_type: type) -> object:
6165
"""Convert a raw string value to the target Python type.
6266
63-
Supported types: str, int, float, bool, timedelta.
67+
Supported types: str, int, float, bool, datetime, timedelta, dict, list.
68+
URL is an alias for str and is handled identically.
6469
6570
Raises:
6671
TypeMismatchError: If the value cannot be converted to the target type.
@@ -78,8 +83,15 @@ def convert_value(raw: str, target_type: type) -> object:
7883
if raw.lower() in ("false", "0"):
7984
return False
8085
raise ValueError(f"cannot convert {raw!r} to bool")
86+
if target_type is datetime:
87+
return datetime.fromisoformat(raw)
8188
if target_type is timedelta:
8289
return _parse_timedelta(raw)
90+
if target_type is dict or target_type is list:
91+
result = json.loads(raw)
92+
if not isinstance(result, target_type):
93+
raise ValueError(f"expected {target_type.__name__}, got {type(result).__name__}")
94+
return result
8395
except (ValueError, OverflowError) as e:
8496
raise TypeMismatchError(f"cannot convert {raw!r} to {target_type.__name__}: {e}") from e
8597

sdk/tests/test_convert.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Tests for type conversion."""
22

3-
from datetime import timedelta
3+
from datetime import datetime, timedelta, timezone
44

55
import pytest
66

7-
from opendecree._convert import _parse_timedelta, convert_value, typed_value_to_string
7+
from opendecree._convert import URL, _parse_timedelta, convert_value, typed_value_to_string
88
from opendecree.errors import TypeMismatchError
99

1010

@@ -78,7 +78,60 @@ def test_convert_timedelta_invalid():
7878

7979
def test_convert_unsupported_type():
8080
with pytest.raises(TypeMismatchError, match="unsupported type"):
81-
convert_value("hello", list) # type: ignore[arg-type]
81+
convert_value("hello", bytes) # type: ignore[arg-type]
82+
83+
84+
def test_convert_datetime_utc():
85+
result = convert_value("2023-11-14T22:13:20Z", datetime)
86+
assert isinstance(result, datetime)
87+
assert result == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc)
88+
89+
90+
def test_convert_datetime_with_offset():
91+
result = convert_value("2023-11-14T22:13:20+00:00", datetime)
92+
assert isinstance(result, datetime)
93+
assert result.year == 2023
94+
95+
96+
def test_convert_datetime_invalid():
97+
with pytest.raises(TypeMismatchError, match="cannot convert"):
98+
convert_value("not-a-datetime", datetime)
99+
100+
101+
def test_convert_dict():
102+
result = convert_value('{"key": "val", "n": 1}', dict)
103+
assert result == {"key": "val", "n": 1}
104+
105+
106+
def test_convert_dict_invalid_json():
107+
with pytest.raises(TypeMismatchError, match="cannot convert"):
108+
convert_value("not-json", dict)
109+
110+
111+
def test_convert_dict_wrong_type():
112+
with pytest.raises(TypeMismatchError, match="cannot convert"):
113+
convert_value("[1, 2]", dict)
114+
115+
116+
def test_convert_list():
117+
result = convert_value('[1, "two", true]', list)
118+
assert result == [1, "two", True]
119+
120+
121+
def test_convert_list_invalid_json():
122+
with pytest.raises(TypeMismatchError, match="cannot convert"):
123+
convert_value("not-json", list)
124+
125+
126+
def test_convert_list_wrong_type():
127+
with pytest.raises(TypeMismatchError, match="cannot convert"):
128+
convert_value('{"a": 1}', list)
129+
130+
131+
def test_convert_url():
132+
result = convert_value("https://example.com/path", URL)
133+
assert result == "https://example.com/path"
134+
assert isinstance(result, str)
82135

83136

84137
def test_parse_timedelta_empty():

0 commit comments

Comments
 (0)