From 496135d127c0aade6b005a8f76f401489dc3d4a3 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Wed, 20 May 2026 10:35:21 +0300 Subject: [PATCH 1/2] 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 Closes #50 --- sdk/src/opendecree/__init__.py | 2 ++ sdk/src/opendecree/_convert.py | 18 +++++++++-- sdk/tests/test_convert.py | 59 ++++++++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/sdk/src/opendecree/__init__.py b/sdk/src/opendecree/__init__.py index 0f8a1e8..02fb5b2 100644 --- a/sdk/src/opendecree/__init__.py +++ b/sdk/src/opendecree/__init__.py @@ -23,6 +23,7 @@ TypeMismatchError, UnavailableError, ) +from opendecree._convert import URL from opendecree.types import Change, ConfigValue, FieldUpdate, ServerVersion from opendecree.watcher import ConfigWatcher, WatchedField @@ -48,6 +49,7 @@ "RetryConfig", "ServerVersion", "TypeMismatchError", + "URL", "UnavailableError", "WatchedField", "__version__", diff --git a/sdk/src/opendecree/_convert.py b/sdk/src/opendecree/_convert.py index 4f1f3f7..9126460 100644 --- a/sdk/src/opendecree/_convert.py +++ b/sdk/src/opendecree/_convert.py @@ -2,15 +2,19 @@ The server stores all values internally as strings. The SDK converts between the proto TypedValue representation and Python types (str, int, float, bool, -timedelta) at the boundary. +datetime, timedelta, dict, list) at the boundary. """ from __future__ import annotations -from datetime import timedelta +import json +from datetime import datetime, timedelta from opendecree.errors import TypeMismatchError +# Type alias for url-typed fields — semantically distinct from plain str. +URL = str + def _parse_timedelta(s: str) -> timedelta: """Parse a Go-style duration string (e.g., '24h', '30m', '500ms') to timedelta. @@ -60,7 +64,8 @@ def _parse_timedelta(s: str) -> timedelta: def convert_value(raw: str, target_type: type) -> object: """Convert a raw string value to the target Python type. - Supported types: str, int, float, bool, timedelta. + Supported types: str, int, float, bool, datetime, timedelta, dict, list. + URL is an alias for str and is handled identically. Raises: TypeMismatchError: If the value cannot be converted to the target type. @@ -78,8 +83,15 @@ def convert_value(raw: str, target_type: type) -> object: if raw.lower() in ("false", "0"): return False raise ValueError(f"cannot convert {raw!r} to bool") + if target_type is datetime: + return datetime.fromisoformat(raw) if target_type is timedelta: return _parse_timedelta(raw) + if target_type is dict or target_type is list: + result = json.loads(raw) + if not isinstance(result, target_type): + raise ValueError(f"expected {target_type.__name__}, got {type(result).__name__}") + return result except (ValueError, OverflowError) as e: raise TypeMismatchError(f"cannot convert {raw!r} to {target_type.__name__}: {e}") from e diff --git a/sdk/tests/test_convert.py b/sdk/tests/test_convert.py index 39aad37..5ed47ef 100644 --- a/sdk/tests/test_convert.py +++ b/sdk/tests/test_convert.py @@ -1,10 +1,10 @@ """Tests for type conversion.""" -from datetime import timedelta +from datetime import datetime, timedelta, timezone import pytest -from opendecree._convert import _parse_timedelta, convert_value, typed_value_to_string +from opendecree._convert import URL, _parse_timedelta, convert_value, typed_value_to_string from opendecree.errors import TypeMismatchError @@ -78,7 +78,60 @@ def test_convert_timedelta_invalid(): def test_convert_unsupported_type(): with pytest.raises(TypeMismatchError, match="unsupported type"): - convert_value("hello", list) # type: ignore[arg-type] + convert_value("hello", bytes) # type: ignore[arg-type] + + +def test_convert_datetime_utc(): + result = convert_value("2023-11-14T22:13:20Z", datetime) + assert isinstance(result, datetime) + assert result == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc) + + +def test_convert_datetime_with_offset(): + result = convert_value("2023-11-14T22:13:20+00:00", datetime) + assert isinstance(result, datetime) + assert result.year == 2023 + + +def test_convert_datetime_invalid(): + with pytest.raises(TypeMismatchError, match="cannot convert"): + convert_value("not-a-datetime", datetime) + + +def test_convert_dict(): + result = convert_value('{"key": "val", "n": 1}', dict) + assert result == {"key": "val", "n": 1} + + +def test_convert_dict_invalid_json(): + with pytest.raises(TypeMismatchError, match="cannot convert"): + convert_value("not-json", dict) + + +def test_convert_dict_wrong_type(): + with pytest.raises(TypeMismatchError, match="cannot convert"): + convert_value("[1, 2]", dict) + + +def test_convert_list(): + result = convert_value('[1, "two", true]', list) + assert result == [1, "two", True] + + +def test_convert_list_invalid_json(): + with pytest.raises(TypeMismatchError, match="cannot convert"): + convert_value("not-json", list) + + +def test_convert_list_wrong_type(): + with pytest.raises(TypeMismatchError, match="cannot convert"): + convert_value('{"a": 1}', list) + + +def test_convert_url(): + result = convert_value("https://example.com/path", URL) + assert result == "https://example.com/path" + assert isinstance(result, str) def test_parse_timedelta_empty(): From 6615f6abb4c8ba8e8d2509f13489c5640cf1e999 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Wed, 20 May 2026 10:36:48 +0300 Subject: [PATCH 2/2] chore(convert): apply ruff import and __all__ sort fixes Co-Authored-By: Claude --- sdk/src/opendecree/__init__.py | 4 ++-- sdk/tests/test_convert.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/src/opendecree/__init__.py b/sdk/src/opendecree/__init__.py index 02fb5b2..5102eaf 100644 --- a/sdk/src/opendecree/__init__.py +++ b/sdk/src/opendecree/__init__.py @@ -7,6 +7,7 @@ SUPPORTED_SERVER_VERSION = ">=0.3.0,<1.0.0" PROTO_VERSION = "v1" +from opendecree._convert import URL from opendecree._retry import RetryConfig from opendecree.async_client import AsyncConfigClient from opendecree.async_watcher import AsyncConfigWatcher, AsyncWatchedField @@ -23,13 +24,13 @@ TypeMismatchError, UnavailableError, ) -from opendecree._convert import URL from opendecree.types import Change, ConfigValue, FieldUpdate, ServerVersion from opendecree.watcher import ConfigWatcher, WatchedField __all__ = [ "PROTO_VERSION", "SUPPORTED_SERVER_VERSION", + "URL", "AlreadyExistsError", "AsyncConfigClient", "AsyncConfigWatcher", @@ -49,7 +50,6 @@ "RetryConfig", "ServerVersion", "TypeMismatchError", - "URL", "UnavailableError", "WatchedField", "__version__", diff --git a/sdk/tests/test_convert.py b/sdk/tests/test_convert.py index 5ed47ef..7dc121a 100644 --- a/sdk/tests/test_convert.py +++ b/sdk/tests/test_convert.py @@ -1,6 +1,6 @@ """Tests for type conversion.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest @@ -84,7 +84,7 @@ def test_convert_unsupported_type(): def test_convert_datetime_utc(): result = convert_value("2023-11-14T22:13:20Z", datetime) assert isinstance(result, datetime) - assert result == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc) + assert result == datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC) def test_convert_datetime_with_offset():