Skip to content

Commit fae81a2

Browse files
committed
feat: Add automatic type conversion for Type_Safe__Dict and improve collection support
- Add try_convert() method to Type_Safe__Base for safe type conversions - Converts values when expected type is a subclass of value's type (e.g., str → Safe_Id) - Handles Any type and subscripted generics (Callable, Union, etc.) - Prevents unwanted conversions (e.g., int → str) maintaining type safety - Enhance Type_Safe__Dict functionality - Automatic type conversion in __setitem__ for both keys and values - Add context manager support (__enter__/__exit__) - Add keys() and values() methods returning Type_Safe__List instances - Add context manager support to Type_Safe__List for consistency - Fix bug where Type_Safe__Dict wouldn't accept valid subclass conversions - Now accepts strings where Safe_Id expected (since Safe_Id extends str) - Maintains type safety by only allowing base class → subclass conversions - Reorganize tests: move regression tests to proper test file This improves developer experience by reducing boilerplate while maintaining the strong type safety guarantees that Type_Safe provides. Fixes: Type_Safe__Dict string to Safe_Id conversion
1 parent cd6cfcf commit fae81a2

File tree

6 files changed

+263
-33
lines changed

6 files changed

+263
-33
lines changed

docs/type_safe/safe-str-int-float-classes-in-osbot-utils.md

Lines changed: 137 additions & 0 deletions
Large diffs are not rendered by default.

osbot_utils/type_safe/Type_Safe__Base.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,25 @@ def is_instance_of_type(self, item, expected_type):
104104
actual_type_name = type_str(type(item))
105105
raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'")
106106

107-
# def json(self):
108-
# pass
107+
def try_convert(self, value, expected_type): # Try to convert value to expected type using Type_Safe conversion logic.
108+
109+
if expected_type is Any: # Handle Any type
110+
return value
111+
112+
origin = type_safe_cache.get_origin(expected_type) # Handle subscripted generics (like Callable, Union, etc.)
113+
if origin is not None:
114+
return value # Can't convert generic types, let type check handle it
115+
116+
if isinstance(value, expected_type): # If already correct type, return as-is
117+
return value
118+
119+
if isinstance(expected_type, type) and type(value) in [str, int, float]: # For types that are subclasses of built-ins (like Safe_Id extends str)
120+
if issubclass(expected_type, type(value)): # Only convert if the value's type is a base class of expected_type. e.g., str → Safe_Id (ok), but not int → str (not ok)
121+
return expected_type(value) # convert value to expected_type
122+
123+
124+
# Return original if no conversion possible
125+
return value
109126

110127
# todo: see if we should/can move this to the Objects.py file
111128
def type_str(tp):

osbot_utils/type_safe/Type_Safe__Dict.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from typing import Type
2+
3+
from osbot_utils.type_safe.Type_Safe__List import Type_Safe__List
4+
25
from osbot_utils.type_safe.Type_Safe__Base import Type_Safe__Base
36

47
class Type_Safe__Dict(Type_Safe__Base, dict):
@@ -9,10 +12,15 @@ def __init__(self, expected_key_type, expected_value_type, *args, **kwargs):
912
self.expected_value_type = expected_value_type
1013

1114
def __setitem__(self, key, value): # Check type-safety before allowing assignment.
15+
key = self.try_convert(key , self.expected_key_type )
16+
value = self.try_convert(value, self.expected_value_type)
1217
self.is_instance_of_type(key , self.expected_key_type)
1318
self.is_instance_of_type(value, self.expected_value_type)
1419
super().__setitem__(key, value)
1520

21+
def __enter__(self): return self
22+
def __exit__ (self, type, value, traceback): pass
23+
1624
# def __repr__(self):
1725
# key_type_name = type_str(self.expected_key_type)
1826
# value_type_name = type_str(self.expected_value_type)
@@ -38,3 +46,9 @@ def json(self):
3846
else: # Regular values can be used as-is
3947
result[key] = value
4048
return result
49+
50+
def keys(self) -> Type_Safe__List:
51+
return Type_Safe__List(self.expected_key_type, super().keys())
52+
53+
def values(self) -> Type_Safe__List:
54+
return Type_Safe__List(self.expected_value_type, super().values())

osbot_utils/type_safe/Type_Safe__List.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ def __repr__(self):
1010
expected_type_name = type_str(self.expected_type)
1111
return f"list[{expected_type_name}] with {len(self)} elements"
1212

13+
def __enter__(self): return self
14+
def __exit__ (self, type, value, traceback): pass
15+
1316
def append(self, item):
1417
from osbot_utils.type_safe.Type_Safe import Type_Safe
1518
if type(self.expected_type) is type and issubclass(self.expected_type, Type_Safe) and type(item) is dict: # if self.expected_type is Type_Safe and we have a dict

tests/unit/type_safe/_bugs/test_Type_Safe__Dict__bugs.py

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,18 @@
11
import pytest
22
from typing import Any, Dict, Type, Set
33
from unittest import TestCase
4-
from osbot_utils.type_safe.Type_Safe import Type_Safe
5-
from osbot_utils.type_safe.Type_Safe__Dict import Type_Safe__Dict
64

5+
from osbot_utils.type_safe.Type_Safe__List import Type_Safe__List
76

8-
class test_Type_Safe__Dict__bugs(TestCase):
9-
10-
def test__bug__doesnt_support__nested__json__with_mixed_content(self):
11-
class TestTypeSafe(Type_Safe):
12-
value: str
7+
from osbot_utils.utils.Objects import base_classes
138

14-
safe_dict = Type_Safe__Dict(str, Any)
15-
safe_dict["number"] = 42
16-
safe_dict["string"] = "text"
17-
safe_dict["type_safe"] = TestTypeSafe(value="safe")
18-
safe_dict["list"] = [1, TestTypeSafe(value="in_list"), {"nested": TestTypeSafe(value="in_dict")}]
19-
safe_dict["dict"] = {
20-
"normal": "value",
21-
"safe_obj": TestTypeSafe(value="in_nested_dict")
22-
}
9+
from osbot_utils.helpers.Safe_Id import Safe_Id
2310

11+
from osbot_utils.type_safe.Type_Safe import Type_Safe
12+
from osbot_utils.type_safe.Type_Safe__Dict import Type_Safe__Dict
2413

25-
expected = {
26-
"number": 42,
27-
"string": "text",
28-
"type_safe": {"value": "safe"},
29-
"list": [1, {"value": "in_list"}, {"nested": {"value": "in_dict"}}],
30-
"dict": {
31-
"normal": "value",
32-
"safe_obj": {"value": "in_nested_dict"}
33-
}
34-
}
35-
assert safe_dict.json() != expected # BUG should be equal
36-
assert safe_dict.json()['list'][2]['nested'] != {"value": "in_dict"}
37-
assert safe_dict.json()['list'][2]['nested'].value == 'in_dict'
38-
assert type(safe_dict.json()['list'][2]['nested']) is TestTypeSafe
3914

15+
class test_Type_Safe__Dict__bugs(TestCase):
4016

4117
def test__bug__json__with_nested_dicts(self):
4218
class TestTypeSafe(Type_Safe):

tests/unit/type_safe/_regression/test_Type_Safe__Dict__regression.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,95 @@
11
import pytest
22
from unittest import TestCase
3-
from typing import Dict, Type, Set
3+
from typing import Dict, Type, Set, Any
4+
from osbot_utils.type_safe.Type_Safe__List import Type_Safe__List
5+
from osbot_utils.utils.Objects import base_classes
6+
7+
from osbot_utils.helpers.Safe_Id import Safe_Id
8+
49
from osbot_utils.type_safe.Type_Safe import Type_Safe
510
from osbot_utils.type_safe.Type_Safe__Dict import Type_Safe__Dict
611

712

813
class test_Type_Safe__Dict__regression(TestCase):
914

15+
def test__regression__str_to_safe_id__conversion_not_supports(self):
16+
class An_Class(Type_Safe):
17+
an_dict: Dict[Safe_Id, Safe_Id]
18+
an_class = An_Class()
19+
an_class.an_dict[Safe_Id("aaa")] = Safe_Id("bbbb") # strongly typed
20+
assert isinstance(Safe_Id, str) is False # confirm Safe_Id is not a direct string
21+
assert issubclass(Safe_Id, str) is True # but has str as a base class
22+
assert base_classes(Safe_Id) == [str, object] # also confirmed by the base class list
23+
#with pytest.raises(TypeError, match="Expected 'Safe_Id', but got 'str'") :
24+
# an_class.an_dict[Safe_Id("aaa")] = "bbbb" # FIXED: BUG: this should be supported since we can convert the str into Safe_Id
25+
#with pytest.raises(TypeError, match="Expected 'Safe_Id', but got 'str'") :
26+
# an_class.an_dict["ccc"] = Safe_Id("dddd") # FIXED: BUG: this should be supported since we can convert the str into Safe_Id
27+
28+
# confirm direct assigment now works
29+
an_class.an_dict[Safe_Id("aaa")] = "bbbb"
30+
an_class.an_dict["ccc" ] = Safe_Id("dddd")
31+
assert type(an_class.an_dict["aaa"]) is Safe_Id
32+
assert type(an_class.an_dict["ccc"]) is Safe_Id
33+
for key, value in an_class.an_dict.items():
34+
assert type(key) is Safe_Id
35+
assert type(value) is Safe_Id
36+
37+
# confirm kwargs assigment now works
38+
kwargs = dict(an_dict={ "an_key_1" :"an_value_1" ,
39+
Safe_Id("an_key_2"): "an_value_2" ,
40+
"an_key_3" : Safe_Id("an_value_3" )})
41+
an_class_2 = An_Class(**kwargs)
42+
43+
with an_class_2.an_dict as _:
44+
for key, value in _.items():
45+
assert type(key) is Safe_Id
46+
assert type(value) is Safe_Id
47+
48+
with an_class_2.an_dict.keys() as _:
49+
assert type(_) is Type_Safe__List
50+
assert _.expected_type is Safe_Id
51+
assert _ == ['an_key_1', 'an_key_2', 'an_key_3']
52+
assert _ == [Safe_Id('an_key_1'), 'an_key_2', 'an_key_3']
53+
assert _ == [Safe_Id('an_key_1'), Safe_Id('an_key_2'), Safe_Id('an_key_3')]
54+
55+
with an_class_2.an_dict.values() as _:
56+
assert type(_) is Type_Safe__List
57+
assert _.expected_type is Safe_Id
58+
assert _ == [ 'an_value_1', 'an_value_2', 'an_value_3']
59+
assert _ == [Safe_Id('an_value_1'), 'an_value_2', 'an_value_3']
60+
assert _ == [Safe_Id('an_value_1'), Safe_Id('an_value_2'), Safe_Id('an_value_3')]
61+
62+
def test__bug__doesnt_support__nested__json__with_mixed_content(self):
63+
class TestTypeSafe(Type_Safe):
64+
value: str
65+
66+
safe_dict = Type_Safe__Dict(str, Any)
67+
safe_dict["number"] = 42
68+
safe_dict["string"] = "text"
69+
safe_dict["type_safe"] = TestTypeSafe(value="safe")
70+
safe_dict["list"] = [1, TestTypeSafe(value="in_list"), {"nested": TestTypeSafe(value="in_dict")}]
71+
safe_dict["dict"] = {
72+
"normal": "value",
73+
"safe_obj": TestTypeSafe(value="in_nested_dict")
74+
}
75+
76+
77+
expected = {
78+
"number": 42,
79+
"string": "text",
80+
"type_safe": {"value": "safe"},
81+
"list": [1, {"value": "in_list"}, {"nested": {"value": "in_dict"}}],
82+
"dict": {
83+
"normal": "value",
84+
"safe_obj": {"value": "in_nested_dict"}
85+
}
86+
}
87+
assert safe_dict.json() != expected # BUG should be equal
88+
assert safe_dict.json()['list'][2]['nested'] != {"value": "in_dict"}
89+
assert safe_dict.json()['list'][2]['nested'].value == 'in_dict'
90+
assert type(safe_dict.json()['list'][2]['nested']) is TestTypeSafe
91+
92+
1093
def test__regression__type_keys_in_json(self):
1194

1295
class Bug_Type_Keys: # Simple class for testing

0 commit comments

Comments
 (0)