diff --git a/README.md b/README.md index 794c22b5..ba2a78f1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Powerful Python util methods and classes that simplify common apis and tasks. -![Current Release](https://img.shields.io/badge/release-v2.15.0-blue) +![Current Release](https://img.shields.io/badge/release-v2.15.1-blue) [![codecov](https://codecov.io/gh/owasp-sbot/OSBot-Utils/graph/badge.svg?token=GNVW0COX1N)](https://codecov.io/gh/owasp-sbot/OSBot-Utils) diff --git a/osbot_utils/type_safe/Type_Safe__Tuple.py b/osbot_utils/type_safe/Type_Safe__Tuple.py new file mode 100644 index 00000000..90b3fc5d --- /dev/null +++ b/osbot_utils/type_safe/Type_Safe__Tuple.py @@ -0,0 +1,41 @@ +from osbot_utils.type_safe.Type_Safe__Base import Type_Safe__Base, type_str + +class Type_Safe__Tuple(Type_Safe__Base, tuple): + + def __new__(cls, expected_types, items=None): + items = items or tuple() + instance = super().__new__(cls, items) + instance.expected_types = expected_types + return instance + + def __init__(self, expected_types, items=None): # todo: see if we should be assining expected_types to self here + if items: + self.validate_items(items) + + def validate_items(self, items): + if len(items) != len(self.expected_types): + raise ValueError(f"Expected {len(self.expected_types)} elements, got {len(items)}") + for item, expected_type in zip(items, self.expected_types): + try: + self.is_instance_of_type(item, expected_type) + except TypeError as e: + raise TypeError(f"In Type_Safe__Tuple: Invalid type for item: {e}") + + def __repr__(self): + types_str = ', '.join(type_str(t) for t in self.expected_types) + return f"tuple[{types_str}] with {len(self)} elements" + + def json(self): + from osbot_utils.type_safe.Type_Safe import Type_Safe + + result = [] + for item in self: + if isinstance(item, Type_Safe): + result.append(item.json()) + elif isinstance(item, (list, tuple)): + result.append([x.json() if isinstance(x, Type_Safe) else x for x in item]) + elif isinstance(item, dict): + result.append({k: v.json() if isinstance(v, Type_Safe) else v for k, v in item.items()}) + else: + result.append(item) + return result \ No newline at end of file diff --git a/osbot_utils/type_safe/steps/Type_Safe__Step__Default_Value.py b/osbot_utils/type_safe/steps/Type_Safe__Step__Default_Value.py index d90cb07e..fe7bd680 100644 --- a/osbot_utils/type_safe/steps/Type_Safe__Step__Default_Value.py +++ b/osbot_utils/type_safe/steps/Type_Safe__Step__Default_Value.py @@ -3,11 +3,12 @@ import inspect import typing -from osbot_utils.type_safe.Type_Safe__Set import Type_Safe__Set -from osbot_utils.type_safe.shared.Type_Safe__Cache import type_safe_cache -from osbot_utils.utils.Objects import default_value -from osbot_utils.type_safe.Type_Safe__List import Type_Safe__List -from osbot_utils.type_safe.Type_Safe__Dict import Type_Safe__Dict +from osbot_utils.type_safe.Type_Safe__Set import Type_Safe__Set +from osbot_utils.type_safe.Type_Safe__Tuple import Type_Safe__Tuple +from osbot_utils.type_safe.shared.Type_Safe__Cache import type_safe_cache +from osbot_utils.utils.Objects import default_value +from osbot_utils.type_safe.Type_Safe__List import Type_Safe__List +from osbot_utils.type_safe.Type_Safe__Dict import Type_Safe__Dict # Backport implementations of get_args for Python 3.7 # todo: refactor into separate class (focused on past python version compatibility) @@ -27,6 +28,11 @@ class Type_Safe__Step__Default_Value: def default_value(self, _cls, var_type): origin = type_safe_cache.get_origin(var_type) # todo: refactor this to use the get_origin method + + if origin is tuple: + item_types = get_args(var_type) + return Type_Safe__Tuple(expected_types=item_types) + if origin is type: # Special handling for Type[T] # todo: reuse the get_origin value type_args = get_args(var_type) if type_args: @@ -73,8 +79,9 @@ def default_value(self, _cls, var_type): if forward_name == _cls.__name__: # if the forward reference is to the current class (simple name check) item_type = _cls # set the item_type to the current class return Type_Safe__List(expected_type=item_type) # and used it as expected_type in Type_Safe__List - else: - return default_value(var_type) # for all other cases call default_value, which will try to create a default instance + + + return default_value(var_type) # for all other cases call default_value, which will try to create a default instance type_safe_step_default_value = Type_Safe__Step__Default_Value() \ No newline at end of file diff --git a/osbot_utils/type_safe/steps/Type_Safe__Step__From_Json.py b/osbot_utils/type_safe/steps/Type_Safe__Step__From_Json.py index fb12cdd7..267ba244 100644 --- a/osbot_utils/type_safe/steps/Type_Safe__Step__From_Json.py +++ b/osbot_utils/type_safe/steps/Type_Safe__Step__From_Json.py @@ -53,6 +53,9 @@ def deserialize_from_dict(self, _self, data, raise_on_not_found=False): value = self.deserialize_type__using_value(value) elif annotation_origin == type: # Handle type objects inside ForwardRef value = self.deserialize_type__using_value(value) + if annotation_origin is tuple and isinstance(value, list): + # item_types = get_args(annotation) # todo: see if we should do type safety here + value = tuple(value) elif type_safe_annotations.obj_is_attribute_annotation_of_type(_self, key, dict): # handle the case when the value is a dict value = self.deserialize_dict__using_key_value_annotations(_self, key, value) elif type_safe_annotations.obj_is_attribute_annotation_of_type(_self, key, set): # handle the case when the value is a list @@ -99,6 +102,7 @@ def deserialize_from_dict(self, _self, data, raise_on_not_found=False): value = Random_Guid_Short(value) elif type_safe_annotations.obj_is_attribute_annotation_of_type(_self, key, Timestamp_Now): # handle Timestamp_Now value = Timestamp_Now(value) + setattr(_self, key, value) # Direct assignment for primitive types and other structures return _self diff --git a/osbot_utils/utils/Objects.py b/osbot_utils/utils/Objects.py index 8e8a8027..2a761742 100644 --- a/osbot_utils/utils/Objects.py +++ b/osbot_utils/utils/Objects.py @@ -279,7 +279,7 @@ def serialize_to_dict(obj): return obj.name elif isinstance(obj, type): return f"{obj.__module__}.{obj.__name__}" # save the full type name - elif isinstance(obj, list) or isinstance(obj, List): + elif isinstance(obj, (list, tuple, List)): # Added tuple here return [serialize_to_dict(item) for item in obj] elif isinstance(obj, set): return [serialize_to_dict(item) for item in obj] diff --git a/osbot_utils/version b/osbot_utils/version index f855fc0f..0f5b24f1 100644 --- a/osbot_utils/version +++ b/osbot_utils/version @@ -1 +1 @@ -v2.15.0 +v2.15.1 diff --git a/pyproject.toml b/pyproject.toml index f891afa3..fcacbf9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "osbot_utils" -version = "v2.15.0" +version = "v2.15.1" description = "OWASP Security Bot - Utils" authors = ["Dinis Cruz "] license = "MIT" diff --git a/tests/unit/type_safe/_bugs/test_Type_Safe__bugs.py b/tests/unit/type_safe/_bugs/test_Type_Safe__bugs.py index 54dd9dbe..6da5387b 100644 --- a/tests/unit/type_safe/_bugs/test_Type_Safe__bugs.py +++ b/tests/unit/type_safe/_bugs/test_Type_Safe__bugs.py @@ -4,11 +4,66 @@ from unittest import TestCase from osbot_utils.type_safe.Type_Safe import Type_Safe from osbot_utils.base_classes.Kwargs_To_Self import Kwargs_To_Self +from osbot_utils.type_safe.Type_Safe__Dict import Type_Safe__Dict from osbot_utils.type_safe.shared.Type_Safe__Convert import type_safe_convert class test_Type_Safe__bugs(TestCase): + def test__bug__roundtrip_tuple_support(self): + class Inner_Data(Type_Safe): + tuple_1 : tuple[str, str] + node_ids: Dict[str, tuple[str, str]] + edge_ids: Dict[str, set[str]] + + class An_Class(Type_Safe): + data: Inner_Data + + an_class = An_Class() + an_class.data = Inner_Data() + an_class.data.node_ids = {"a": ("id1", "id2")} + an_class.data.edge_ids = {"b": {"id3"}} + an_class.data.tuple_1 = ("id1", "id2") + + expected_error = "In tuple at index 0: Expected 'str', but got 'int'" + with pytest.raises(TypeError, match=expected_error): + an_class.data.node_ids = {"b": (123, '123')} # confirm type safety in tuples + # with pytest.raises(TypeError, match=expected_error): + # an_class.data.tuple_1 = (123, '123') # BUG should have raised + + assert type(an_class.data.tuple_1) is tuple # BUG this should be Type_Safe__Tuple + an_class.data.tuple_1 = (123, '123') # BUG should have raised + assert type(an_class.data.tuple_1) is tuple + + assert type(an_class.data.node_ids) is Type_Safe__Dict # this should not be a dict + assert type(an_class.data.edge_ids) is Type_Safe__Dict # correct this should be a dict + + + # expected_error = "Type not serializable" + # with pytest.raises(TypeError, match=expected_error): # BUG, should have worked + # an_class.json() + assert an_class.json() == {'data': {'edge_ids': {'b': ['id3']}, + 'node_ids': {'a': ['id1', 'id2']}, + 'tuple_1': [123, '123']}} # BUG: this should have not happened + + roundtrip_obj = An_Class.from_json(an_class.json()) + + assert roundtrip_obj.json() == an_class.json() + assert roundtrip_obj.data.node_ids == {"a": ("id1", "id2")} + assert roundtrip_obj.data.edge_ids == {"b": {"id3"}} + assert roundtrip_obj.data.tuple_1 == (123, "123") + assert type(roundtrip_obj.data.tuple_1) is tuple + + + # These assertions will help verify the fix once implemented + # assert json_data == {'data': {'node_ids': {'a': ['id1', 'id2']}, + # 'edge_ids': {'b': ['id3', 'id4']}}} + # + # an_class_round_trip = An_Class.from_json(json_data) + # assert type(an_class_round_trip.data.node_ids['a']) is tuple + # assert type(an_class_round_trip.data.edge_ids['b']) is Type_Safe__Set + # assert an_class_round_trip.data.node_ids['a'] == ('id1', 'id2') + # assert an_class_round_trip.data.edge_ids['b'] == {'id3', 'id4'} def test__bug__property_descriptor_handling__doesnt_enforce_type_safety(self): class Test_Class(Type_Safe): diff --git a/tests/unit/type_safe/_regression/test_Type_Safe__regression.py b/tests/unit/type_safe/_regression/test_Type_Safe__regression.py index f5eb06b6..0b371052 100644 --- a/tests/unit/type_safe/_regression/test_Type_Safe__regression.py +++ b/tests/unit/type_safe/_regression/test_Type_Safe__regression.py @@ -14,7 +14,7 @@ from osbot_utils.type_safe.Type_Safe__List import Type_Safe__List from osbot_utils.decorators.methods.cache_on_self import cache_on_self from osbot_utils.helpers.Random_Guid import Random_Guid -from osbot_utils.type_safe.Type_Safe__Set import Type_Safe__Set +from osbot_utils.type_safe.Type_Safe__Set import Type_Safe__Set from osbot_utils.type_safe.shared.Type_Safe__Annotations import type_safe_annotations from osbot_utils.type_safe.validators.Validator__Min import Min from osbot_utils.utils.Json import json_to_str, str_to_json