Skip to content

Commit d3d3085

Browse files
authored
feat(convert): add datetime, dict/list, and URL type support
- Extends convert_value to cover the remaining proto TypedValue variants that previously returned raw strings. - Adds datetime conversion via fromisoformat (RFC 3339 / Z suffix), dict/list via json.loads with type-mismatch guard, and URL as a str alias exported from the top-level package. Closes #50 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 8b51cec commit d3d3085

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
@@ -7,6 +7,7 @@
77
SUPPORTED_SERVER_VERSION = ">=0.3.0,<1.0.0"
88
PROTO_VERSION = "v1"
99

10+
from opendecree._convert import URL
1011
from opendecree._retry import RetryConfig
1112
from opendecree.async_client import AsyncConfigClient
1213
from opendecree.async_watcher import AsyncConfigWatcher, AsyncWatchedField
@@ -29,6 +30,7 @@
2930
__all__ = [
3031
"PROTO_VERSION",
3132
"SUPPORTED_SERVER_VERSION",
33+
"URL",
3234
"AlreadyExistsError",
3335
"AsyncConfigClient",
3436
"AsyncConfigWatcher",

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 UTC, datetime, timedelta
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=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)