Skip to content

Commit 66ae6f3

Browse files
committed
added major new feature to Type_Safe__Dict which now can be used as a base class (in a way that is compatible with how it was used before)
fixed couple more bugs with Type_Safe__Dict usage in Type_Safe objects (making is more consistent)
1 parent f4ef1c1 commit 66ae6f3

File tree

6 files changed

+757
-13
lines changed

6 files changed

+757
-13
lines changed

osbot_utils/type_safe/Type_Safe__Base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ def is_instance_of_type(self, item, expected_type):
122122

123123
def try_convert(self, value, expected_type): # Try to convert value to expected type using Type_Safe conversion logic.
124124

125+
from osbot_utils.type_safe.Type_Safe import Type_Safe
126+
from osbot_utils.type_safe.Type_Safe__Primitive import Type_Safe__Primitive
127+
125128
if expected_type is Any: # Handle Any type
126129
return value
127130

@@ -132,6 +135,17 @@ def try_convert(self, value, expected_type): # Try to convert value to expect
132135
if isinstance(value, expected_type): # If already correct type, return as-is
133136
return value
134137

138+
139+
if (isinstance(expected_type, type) and # Handle dict → Type_Safe conversion
140+
issubclass(expected_type, Type_Safe) and
141+
isinstance(value, dict)):
142+
return expected_type.from_json(value)
143+
144+
if (isinstance(expected_type, type) and # Handle dict → Type_Safe__Primitive conversion
145+
issubclass(expected_type, Type_Safe__Primitive) and
146+
type(value) in [str, int, float]):
147+
return expected_type(value)
148+
135149
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)
136150
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)
137151
return expected_type(value) # convert value to expected_type

osbot_utils/type_safe/type_safe_core/collections/Type_Safe__Dict.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,31 @@
88

99

1010
class Type_Safe__Dict(Type_Safe__Base, dict):
11-
def __init__(self, expected_key_type, expected_value_type, *args, **kwargs):
12-
super().__init__(*args, **kwargs)
11+
expected_key_type : Type = None # Class-level defaults
12+
expected_value_type : Type = None
1313

14-
self.expected_key_type = expected_key_type
15-
self.expected_value_type = expected_value_type
14+
def __init__(self, expected_key_type=None, expected_value_type=None, initial_data=None, **kwargs):
15+
super().__init__() # Initialize empty dict first
16+
17+
18+
if isinstance(expected_key_type, dict) and expected_value_type is None: # Smart detection: if expected_key_type is a dict and expected_value_type is None, then the user is trying to pass initial data in the old style
19+
initial_data = expected_key_type # They're using the pattern: Hash_Mapping({'key': 'value'})
20+
expected_key_type = None # Move the dict to initial_data
21+
22+
self.expected_key_type = expected_key_type or self.__class__.expected_key_type # Use provided types, or fall back to class-level attributes
23+
self.expected_value_type = expected_value_type or self.__class__.expected_value_type
24+
25+
if self.expected_key_type is None or self.expected_value_type is None: # Validate that we have types set (either from args or class)
26+
raise ValueError(f"{self.__class__.__name__} requires expected_key_type and expected_value_type")
27+
28+
if initial_data is not None: # Process initial data through our type-safe __setitem__
29+
if not isinstance(initial_data, dict):
30+
raise TypeError(f"Initial data must be a dict, got {type(initial_data).__name__}")
31+
for key, value in initial_data.items():
32+
self[key] = value # Goes through __setitem__ with validation
33+
34+
for key, value in kwargs.items(): # Also handle keyword arguments (e.g., Hash_Mapping(key1='val1', key2='val2'))
35+
self[key] = value
1636

1737
def __contains__(self, key):
1838
if super().__contains__(key): # First try direct lookup
@@ -105,3 +125,20 @@ def obj(self) -> __:
105125

106126
def values(self) -> Type_Safe__List:
107127
return Type_Safe__List(self.expected_value_type, super().values())
128+
129+
def update(self, other=None, **kwargs):
130+
"""Override update to ensure type safety through __setitem__"""
131+
# Handle dict-like object or iterable of key-value pairs
132+
if other is not None:
133+
if hasattr(other, 'items'):
134+
# Dict-like object
135+
for key, value in other.items():
136+
self[key] = value # Goes through __setitem__
137+
else:
138+
# Iterable of (key, value) pairs
139+
for key, value in other:
140+
self[key] = value
141+
142+
# Handle keyword arguments
143+
for key, value in kwargs.items():
144+
self[key] = value

osbot_utils/type_safe/type_safe_core/steps/Type_Safe__Step__From_Json.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ def deserialize_attribute(self, _self, key, value):
4949
return self.deserialize_type__using_value(value)
5050

5151
if value is not None and type(value) is dict: # Handle forward references first
52+
53+
if isinstance(annotation, type) and issubclass(annotation, Type_Safe__Dict): # Handle Type_Safe__Dict subclasses BEFORE forward references
54+
if hasattr(annotation, 'expected_key_type') and hasattr(annotation, 'expected_value_type'): # Get the expected types from the subclass
55+
if annotation.expected_key_type and annotation.expected_value_type:
56+
dict_instance = annotation(value) # Create instance and populate it
57+
return dict_instance
5258
forward_ref_result = self.handle_forward_references(_self, annotation, value)
5359
if forward_ref_result is not None:
5460
return forward_ref_result

tests/unit/type_safe/type_safe_core/_regression/test_Type_Safe__regression.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -642,9 +642,12 @@ class An_Class_2_A(Type_Safe):
642642
'an_class_2_b': {'an_str': 'value_1'}}
643643

644644
assert An_Class_2_A.from_json(json_data_2).json() == json_data_2
645-
new_error = "Expected 'An_Class_2_B', but got 'dict'"
646-
with pytest.raises(TypeError, match=re.escape(new_error)):
647-
An_Class_2_A(**json_data_2)
645+
646+
assert An_Class_2_A(**json_data_2).obj() == __(an_dict=__(key_1=__(an_str='value_1')),
647+
an_class_2_b=__(an_str='value_1')) # and now this works
648+
649+
assert An_Class_2_A(**json_data_2).obj () == An_Class_2_A.from_json(json_data_2).obj() # and these now match
650+
assert An_Class_2_A(**json_data_2).json() == json_data_2
648651

649652
# so the code below started to fail after the fix to the dict object assissment (see above ""Expected 'An_Class_2_B', but got 'dict'"")
650653
# this feels ok since we should be using from_json to create new nester objects and not **json_data_2
@@ -1510,10 +1513,8 @@ class An_Class_2_A(Type_Safe):
15101513
assert type(an_class_from_json.an_dict['key_1']) is An_Class_2_B
15111514
assert an_class_from_json.an_dict['key_1'].an_str == 'value_1'
15121515

1513-
expected_error_1 = "Expected 'An_Class_2_B', but got 'dict'"
1514-
with pytest.raises(TypeError, match=re.escape(expected_error_1)): # this doesn't work
1515-
An_Class_2_A(**an_class_json) # but that is expected behavior
1516-
#an_class_2b = An_Class_2_B()
1516+
assert An_Class_2_A(**an_class_json).json() == an_class_json
1517+
15171518
an_class_data = {'an_dict': {'key_1': An_Class_2_B(**{'an_str': 'value_1'})}} # since this is how it needs to be done, which works
15181519
an_class_from_kwargs = An_Class_2_A(**an_class_data) # the idea is if a pure json import is needed,
15191520
assert an_class_from_kwargs.json() == an_class_json # then .from_json(..) is what should be used

tests/unit/type_safe/type_safe_core/collections/test_Type_Safe__Dict.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,19 @@ class An_Class(Type_Safe):
222222

223223
an_class = An_Class()
224224
an_class.an_dict_custom['ok'] = CustomType(a=1, b='abc')
225-
with pytest.raises(TypeError, match="Expected 'CustomType', but got 'dict'"):
226-
an_class.an_dict_custom['fail'] = {'a': 2, 'b': 'def'}
225+
226+
assert an_class.obj() == __(an_dict_custom=__(ok=__(a=1, b='abc')))
227+
an_class.an_dict_custom['also_works'] = {'a': 2, 'b': 'def'}
228+
assert an_class.obj() == __(an_dict_custom=__(ok = __(a=1, b='abc'),
229+
also_works = __(a=2, b='def')))
230+
231+
error_message = "On CustomType, invalid type for attribute 'b'. Expected '<class 'str'>' but got '<class 'int'>'"
232+
with pytest.raises(ValueError, match=re.escape(error_message)):
233+
an_class.an_dict_custom['fail'] = {'a': 2, 'b': 42}
234+
235+
error_message = "Expected 'CustomType', but got 'str'"
236+
with pytest.raises(TypeError, match=re.escape(error_message)):
237+
an_class.an_dict_custom['fail'] = "42"
227238

228239
def test__dict_with_empty_collections(self): # Check that empty dict fields can be assigned without issues.
229240
class An_Class(Type_Safe):

0 commit comments

Comments
 (0)