From 7d09f4d4aa56af222667bff71e56bb439482cd30 Mon Sep 17 00:00:00 2001 From: Dinis Cruz Date: Thu, 26 Dec 2024 16:31:53 +0000 Subject: [PATCH 1/4] refactored Type_Safe__List key code into Type_Safe__Base added Type_Safe__Dict to Type_Safe and fixed number of Type_Safe bugs :) added support for simple Forward References in Type_Safe definitions Started adding the Xml_To_Dict class --- osbot_utils/base_classes/Type_Safe.py | 32 ++- osbot_utils/base_classes/Type_Safe__Base.py | 117 ++++++++ osbot_utils/base_classes/Type_Safe__Dict.py | 22 ++ osbot_utils/base_classes/Type_Safe__List.py | 115 +------- osbot_utils/helpers/Xml_To_Dict.py | 87 ++++++ osbot_utils/utils/Objects.py | 11 +- .../unit/base_classes/test_Type_Safe__Dict.py | 257 ++++++++++++++++++ .../unit/base_classes/test_Type_Safe__List.py | 40 ++- .../unit/base_classes/test_Type_Safe__bugs.py | 44 +-- .../test_Type_Safe__regression.py | 83 ++++++ tests/unit/helpers/test_Xml_To_Dict.py | 210 ++++++++++++++ 11 files changed, 843 insertions(+), 175 deletions(-) create mode 100644 osbot_utils/base_classes/Type_Safe__Base.py create mode 100644 osbot_utils/base_classes/Type_Safe__Dict.py create mode 100644 osbot_utils/helpers/Xml_To_Dict.py create mode 100644 tests/unit/base_classes/test_Type_Safe__Dict.py create mode 100644 tests/unit/helpers/test_Xml_To_Dict.py diff --git a/osbot_utils/base_classes/Type_Safe.py b/osbot_utils/base_classes/Type_Safe.py index 005d5c5c..8439ff5a 100644 --- a/osbot_utils/base_classes/Type_Safe.py +++ b/osbot_utils/base_classes/Type_Safe.py @@ -3,7 +3,7 @@ import sys import types -from osbot_utils.utils.Objects import default_value # todo: remove test mocking requirement for this to be here (instead of on the respective method) +from osbot_utils.utils.Objects import default_value # todo: remove test mocking requirement for this to be here (instead of on the respective method) # Backport implementations of get_origin and get_args for Python 3.7 if sys.version_info < (3, 8): # pragma: no cover @@ -23,7 +23,7 @@ def get_args(tp): else: return () else: - from typing import get_origin, get_args + from typing import get_origin, get_args, ForwardRef if sys.version_info >= (3, 10): NoneType = types.NoneType @@ -148,6 +148,7 @@ def __cls_kwargs__(cls, include_base_classes=True): # Return cu def __default__value__(cls, var_type): import typing from osbot_utils.base_classes.Type_Safe__List import Type_Safe__List + from osbot_utils.base_classes.Type_Safe__Dict import Type_Safe__Dict if var_type is typing.Set: # todo: refactor the dict, set and list logic, since they are 90% the same return set() @@ -156,13 +157,28 @@ def __default__value__(cls, var_type): if var_type is typing.Dict: return {} - if get_origin(var_type) is dict: - return {} # todo: add Type_Safe__Dict + + if get_origin(var_type) is dict: # e.g. Dict[key_type, value_type] + key_type, value_type = get_args(var_type) + if isinstance(key_type, ForwardRef): # Handle forward references on key_type --- + forward_name = key_type.__forward_arg__ + if forward_name == cls.__name__: + key_type = cls + if isinstance(value_type, ForwardRef): # Handle forward references on value_type --- + forward_name = value_type.__forward_arg__ + if forward_name == cls.__name__: + value_type = cls + return Type_Safe__Dict(expected_key_type=key_type, expected_value_type=value_type) if var_type is typing.List: - return [] # handle case when List was used with no type information provided + return [] # handle case when List was used with no type information provided + if get_origin(var_type) is list: # if we have list defined as list[type] item_type = get_args(var_type)[0] # get the type that was defined + if isinstance(item_type, ForwardRef): # handle the case when the type is a forward reference + forward_name = item_type.__forward_arg__ + 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 @@ -258,16 +274,16 @@ def update_from_kwargs(self, **kwargs): # Update instanc return self def deserialize_dict__using_key_value_annotations(self, key, value): + from osbot_utils.base_classes.Type_Safe__Dict import Type_Safe__Dict + dict_annotations_tuple = get_args(self.__annotations__[key]) if not dict_annotations_tuple: # happens when the value is a dict/Dict with no annotations return value if not type(value) is dict: return value - #key_class = get_args(self.__annotations__[key])[0] - #value_class = get_args(self.__annotations__[key])[1] key_class = dict_annotations_tuple[0] value_class = dict_annotations_tuple[1] - new_value = {} + new_value = Type_Safe__Dict(expected_key_type=key_class, expected_value_type=value_class) for dict_key, dict_value in value.items(): if issubclass(key_class, Type_Safe): diff --git a/osbot_utils/base_classes/Type_Safe__Base.py b/osbot_utils/base_classes/Type_Safe__Base.py new file mode 100644 index 00000000..2fc5cd13 --- /dev/null +++ b/osbot_utils/base_classes/Type_Safe__Base.py @@ -0,0 +1,117 @@ +from typing import get_origin, get_args, Union, Optional, Any, ForwardRef + +EXACT_TYPE_MATCH = (int, float, str, bytes, bool, complex) + +class Type_Safe__Base: + def is_instance_of_type(self, item, expected_type): + if expected_type is Any: + return True + if isinstance(expected_type, ForwardRef): # todo: add support for ForwardRef + return True + origin = get_origin(expected_type) + args = get_args(expected_type) + if origin is None: + if expected_type in EXACT_TYPE_MATCH: + if type(item) is expected_type: + return True + else: + expected_type_name = type_str(expected_type) + actual_type_name = type_str(type(item)) + raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") + else: + if isinstance(item, expected_type): # Non-parameterized type + return True + else: + expected_type_name = type_str(expected_type) + actual_type_name = type_str(type(item)) + raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") + + elif origin is list and args: # Expected type is List[...] + (item_type,) = args + if not isinstance(item, list): + expected_type_name = type_str(expected_type) + actual_type_name = type_str(type(item)) + raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") + for idx, elem in enumerate(item): + try: + self.is_instance_of_type(elem, item_type) + except TypeError as e: + raise TypeError(f"In list at index {idx}: {e}") + return True + elif origin is dict and args: # Expected type is Dict[...] + key_type, value_type = args + if not isinstance(item, dict): + expected_type_name = type_str(expected_type) + actual_type_name = type_str(type(item)) + raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") + for k, v in item.items(): + try: + self.is_instance_of_type(k, key_type) + except TypeError as e: + raise TypeError(f"In dict key '{k}': {e}") + try: + self.is_instance_of_type(v, value_type) + except TypeError as e: + raise TypeError(f"In dict value for key '{k}': {e}") + return True + elif origin is tuple: + if not isinstance(item, tuple): + expected_type_name = type_str(expected_type) + actual_type_name = type_str(type(item)) + raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") + if len(args) != len(item): + raise TypeError(f"Expected tuple of length {len(args)}, but got {len(item)}") + for idx, (elem, elem_type) in enumerate(zip(item, args)): + try: + self.is_instance_of_type(elem, elem_type) + except TypeError as e: + raise TypeError(f"In tuple at index {idx}: {e}") + return True + elif origin is Union or expected_type is Optional: # Expected type is Union[...] + for arg in args: + try: + self.is_instance_of_type(item, arg) + return True + except TypeError: + continue + expected_type_name = type_str(expected_type) + actual_type_name = type_str(type(item)) + raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") + else: + if isinstance(item, origin): + return True + else: + expected_type_name = type_str(expected_type) + actual_type_name = type_str(type(item)) + raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") + +# todo: see if we should/can move this to the Objects.py file +def type_str(tp): + origin = get_origin(tp) + if origin is None: + if hasattr(tp, '__name__'): + return tp.__name__ + else: + return str(tp) + else: + args = get_args(tp) + args_str = ', '.join(type_str(arg) for arg in args) + return f"{origin.__name__}[{args_str}]" + +def get_object_type_str(obj): + if isinstance(obj, dict): + if not obj: + return "Dict[Empty]" + key_types = set(type(k).__name__ for k in obj.keys()) + value_types = set(type(v).__name__ for v in obj.values()) + key_type_str = ', '.join(sorted(key_types)) + value_type_str = ', '.join(sorted(value_types)) + return f"Dict[{key_type_str}, {value_type_str}]" + elif isinstance(obj, list): + if not obj: + return "List[Empty]" + elem_types = set(type(e).__name__ for e in obj) + elem_type_str = ', '.join(sorted(elem_types)) + return f"List[{elem_type_str}]" + else: + return type(obj).__name__ \ No newline at end of file diff --git a/osbot_utils/base_classes/Type_Safe__Dict.py b/osbot_utils/base_classes/Type_Safe__Dict.py new file mode 100644 index 00000000..3d6711c6 --- /dev/null +++ b/osbot_utils/base_classes/Type_Safe__Dict.py @@ -0,0 +1,22 @@ +from osbot_utils.base_classes.Type_Safe__Base import type_str, Type_Safe__Base + +class Type_Safe__Dict(Type_Safe__Base, dict): + def __init__(self, expected_key_type, expected_value_type, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.expected_key_type = expected_key_type + self.expected_value_type = expected_value_type + + for k, v in self.items(): # check type-safety of ctor arguments + self.is_instance_of_type(k, self.expected_key_type ) + self.is_instance_of_type(v, self.expected_value_type) + + def __setitem__(self, key, value): # Check type-safety before allowing assignment. + self.is_instance_of_type(key, self.expected_key_type) + self.is_instance_of_type(value, self.expected_value_type) + super().__setitem__(key, value) + + def __repr__(self): + key_type_name = type_str(self.expected_key_type) + value_type_name = type_str(self.expected_value_type) + return f"dict[{key_type_name}, {value_type_name}] with {len(self)} entries" diff --git a/osbot_utils/base_classes/Type_Safe__List.py b/osbot_utils/base_classes/Type_Safe__List.py index 8368574d..e48176d6 100644 --- a/osbot_utils/base_classes/Type_Safe__List.py +++ b/osbot_utils/base_classes/Type_Safe__List.py @@ -1,8 +1,7 @@ -from typing import get_origin, get_args, Union, Optional, Any, ForwardRef +from osbot_utils.base_classes.Type_Safe__Base import Type_Safe__Base, type_str -EXACT_TYPE_MATCH = (int, float, str, bytes, bool, complex) -class Type_Safe__List(list): +class Type_Safe__List(Type_Safe__Base, list): def __init__(self, expected_type, *args): super().__init__(*args) @@ -19,115 +18,5 @@ def append(self, item): raise TypeError(f"In Type_Safe__List: Invalid type for item: {e}") super().append(item) - def is_instance_of_type(self, item, expected_type): - if expected_type is Any: - return True - if isinstance(expected_type, ForwardRef): # todo: add support for ForwardRef - return True - origin = get_origin(expected_type) - args = get_args(expected_type) - if origin is None: - if expected_type in EXACT_TYPE_MATCH: - if type(item) is expected_type: - return True - else: - expected_type_name = type_str(expected_type) - actual_type_name = type_str(type(item)) - raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") - else: - if isinstance(item, expected_type): # Non-parameterized type - return True - else: - expected_type_name = type_str(expected_type) - actual_type_name = type_str(type(item)) - raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") - elif origin is list and args: # Expected type is List[...] - (item_type,) = args - if not isinstance(item, list): - expected_type_name = type_str(expected_type) - actual_type_name = type_str(type(item)) - raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") - for idx, elem in enumerate(item): - try: - self.is_instance_of_type(elem, item_type) - except TypeError as e: - raise TypeError(f"In list at index {idx}: {e}") - return True - elif origin is dict and args: # Expected type is Dict[...] - key_type, value_type = args - if not isinstance(item, dict): - expected_type_name = type_str(expected_type) - actual_type_name = type_str(type(item)) - raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") - for k, v in item.items(): - try: - self.is_instance_of_type(k, key_type) - except TypeError as e: - raise TypeError(f"In dict key '{k}': {e}") - try: - self.is_instance_of_type(v, value_type) - except TypeError as e: - raise TypeError(f"In dict value for key '{k}': {e}") - return True - elif origin is tuple: - if not isinstance(item, tuple): - expected_type_name = type_str(expected_type) - actual_type_name = type_str(type(item)) - raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") - if len(args) != len(item): - raise TypeError(f"Expected tuple of length {len(args)}, but got {len(item)}") - for idx, (elem, elem_type) in enumerate(zip(item, args)): - try: - self.is_instance_of_type(elem, elem_type) - except TypeError as e: - raise TypeError(f"In tuple at index {idx}: {e}") - return True - elif origin is Union or expected_type is Optional: # Expected type is Union[...] - for arg in args: - try: - self.is_instance_of_type(item, arg) - return True - except TypeError: - continue - expected_type_name = type_str(expected_type) - actual_type_name = type_str(type(item)) - raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") - else: - if isinstance(item, origin): - return True - else: - expected_type_name = type_str(expected_type) - actual_type_name = type_str(type(item)) - raise TypeError(f"Expected '{expected_type_name}', but got '{actual_type_name}'") -# todo: see if we should/can move this to the Objects.py file -def type_str(tp): - origin = get_origin(tp) - if origin is None: - if hasattr(tp, '__name__'): - return tp.__name__ - else: - return str(tp) - else: - args = get_args(tp) - args_str = ', '.join(type_str(arg) for arg in args) - return f"{origin.__name__}[{args_str}]" - -def get_object_type_str(obj): - if isinstance(obj, dict): - if not obj: - return "Dict[Empty]" - key_types = set(type(k).__name__ for k in obj.keys()) - value_types = set(type(v).__name__ for v in obj.values()) - key_type_str = ', '.join(sorted(key_types)) - value_type_str = ', '.join(sorted(value_types)) - return f"Dict[{key_type_str}, {value_type_str}]" - elif isinstance(obj, list): - if not obj: - return "List[Empty]" - elem_types = set(type(e).__name__ for e in obj) - elem_type_str = ', '.join(sorted(elem_types)) - return f"List[{elem_type_str}]" - else: - return type(obj).__name__ diff --git a/osbot_utils/helpers/Xml_To_Dict.py b/osbot_utils/helpers/Xml_To_Dict.py new file mode 100644 index 00000000..6825847c --- /dev/null +++ b/osbot_utils/helpers/Xml_To_Dict.py @@ -0,0 +1,87 @@ +from typing import Dict, Any, Union +from xml.etree.ElementTree import Element + +from osbot_utils.base_classes.Type_Safe import Type_Safe + +class XML_Attribute(Type_Safe): + name : str + value : str + namespace: str + +class XML_Element(Type_Safe): + attributes: Dict[str, XML_Attribute] + children : Dict[str, Union[str, 'XML_Element']] + + +class Xml_To_Dict(Type_Safe): + xml_data : str = None # Input XML string + root : Element = None # Root ElementTree.Element + namespaces : Dict[str, str] # XML namespaces + xml_dict : Dict[str, Any] # Parsed XML as dictionary + + def setup(self) : + from xml.etree.ElementTree import ParseError + try: + self.load_namespaces() + self.load_root() + + except ParseError as e: + raise ValueError(f"Invalid XML: {str(e)}") + return self + + + def load_namespaces(self): + from xml.etree.ElementTree import iterparse + from io import StringIO + + for event, elem in iterparse(StringIO(self.xml_data), events=("start-ns",)): + self.namespaces[elem[0]] = elem[1] + + def load_root(self): + from xml.etree.ElementTree import fromstring + + self.root = fromstring(self.xml_data) + + def element_to_dict(self, element: Element) -> Union[Dict[str, Any], str]: + """Convert an ElementTree.Element to a dictionary""" + result: Dict[str, Any] = {} + + + if element.attrib: # Handle attributes + result.update(element.attrib) + + # Handle child elements + child_nodes: Dict[str, Any] = {} + for child in element: + tag = child.tag # Remove namespace prefix if present + if '}' in tag: + tag = tag.split('}', 1)[1] + + child_data = self.element_to_dict(child) + + if tag in child_nodes: + if not isinstance(child_nodes[tag], list): + child_nodes[tag] = [child_nodes[tag]] + child_nodes[tag].append(child_data) + else: + child_nodes[tag] = child_data + + # Handle text content + text = element.text.strip() if element.text else '' + if text: + if child_nodes or result: + result['_text'] = text + else: + return text + elif not child_nodes and not result: # Make sure we return text content even for empty nodes + return text + + # Combine results + if child_nodes: + result.update(child_nodes) + + return result + + def parse(self) -> Dict[str, Any]: # Convert parsed XML to dictionary + self.xml_dict = self.element_to_dict(self.root) + return self \ No newline at end of file diff --git a/osbot_utils/utils/Objects.py b/osbot_utils/utils/Objects.py index fa62bdae..765e633f 100644 --- a/osbot_utils/utils/Objects.py +++ b/osbot_utils/utils/Objects.py @@ -30,14 +30,17 @@ def are_types_compatible_for_assigment(source_type, target_type): import types import typing + if isinstance(target_type, str): # If the "target_type" is a forward reference (string), handle it here. + if target_type == source_type.__name__: # Simple check: does the string match the actual class name + return True if source_type is target_type: return True if source_type is int and target_type is float: return True - if target_type in source_type.__mro__: # this means that the source_type has the target_type has of its base types + if target_type in source_type.__mro__: # this means that the source_type has the target_type has of its base types return True - if target_type is callable: # handle case where callable was used as the target type - if source_type is types.MethodType: # and a method or function was used as the source type + if target_type is callable: # handle case where callable was used as the target type + if source_type is types.MethodType: # and a method or function was used as the source type return True if source_type is types.FunctionType: return True @@ -413,7 +416,7 @@ def value_type_matches_obj_annotation_for_attr(target, attr_name, value): origin_attr_type = get_origin(attr_type) # to handle when type definion contains an generic if origin_attr_type is typing.Union: args = get_args(attr_type) - if len(args)==2 and args[1] is type(None): # todo: find a better way to do this, since this is handling an edge case when origin_attr_type is Optional (whcih is an shorthand for Union[X, None] ) + if len(args)==2 and args[1] is type(None): # todo: find a better way to do this, since this is handling an edge case when origin_attr_type is Optional (which is an shorthand for Union[X, None] ) attr_type = args[0] origin_attr_type = get_origin(attr_type) diff --git a/tests/unit/base_classes/test_Type_Safe__Dict.py b/tests/unit/base_classes/test_Type_Safe__Dict.py new file mode 100644 index 00000000..2a30728e --- /dev/null +++ b/tests/unit/base_classes/test_Type_Safe__Dict.py @@ -0,0 +1,257 @@ +import re +import sys +import pytest +from unittest import TestCase +from typing import Dict, Union, Optional, Any, Callable, List, Tuple +from osbot_utils.base_classes.Type_Safe import Type_Safe +from osbot_utils.base_classes.Type_Safe__Dict import Type_Safe__Dict + + +class test_Type_Safe__Dict(TestCase): + + def test__dict_from_json__enforces_type_safety(self): + class An_Class__Item(Type_Safe): + an_str: str + + class An_Class(Type_Safe): + items: Dict[str, An_Class__Item] + + json_data = {'items': {'a': {'an_str': 'abc'}}} + + an_class = An_Class.from_json(json_data) + assert type(an_class.items ) is Type_Safe__Dict + assert type(an_class.items['a']) is An_Class__Item + assert an_class.items['a'].an_str == 'abc' + + def test__dict_with_simple_types(self): # Similar to the Type_Safe__List test, but for dictionaries. + if sys.version_info < (3, 10): + pytest.skip("Skipping test that doesn't work on 3.9 or lower") + + class An_Class(Type_Safe): + an_dict_any: dict + an_dict_str_int: dict[str, int] # Python 3.9+ or 3.10+ style + an_dict_int_str: Dict[int, str] # Classic typing style + + an_class = An_Class() + assert type(an_class.an_dict_any) is dict + assert type(an_class.an_dict_str_int) is Type_Safe__Dict + assert type(an_class.an_dict_int_str) is Type_Safe__Dict + + assert an_class.an_dict_any == {} + assert an_class.an_dict_str_int == {} + assert an_class.an_dict_int_str == {} + + an_class.an_dict_any ['key' ] = 'value' + an_class.an_dict_any [1 ] = 'one' + an_class.an_dict_str_int['one' ] = 1 + an_class.an_dict_int_str[1 ] = 'one' + + assert an_class.json() == { 'an_dict_any' : {1 : 'one', 'key': 'value'}, + 'an_dict_int_str': {1 : 'one' }, + 'an_dict_str_int': {'one': 1 }} + + + an_class.an_dict_str_int['one'] = 1 # Test an_dict_str_int -> Dict[str, int] + with pytest.raises(TypeError, match="Expected 'int', but got 'str'"): + an_class.an_dict_str_int['two'] = '2' + + with pytest.raises(TypeError, match="Expected 'str', but got 'int'"): + an_class.an_dict_str_int[3] = 3 # key must be str + + an_class.an_dict_int_str[1] = 'one' # Test an_dict_int_str -> Dict[int, str] + with pytest.raises(TypeError, match="Expected 'str', but got 'int'"): + an_class.an_dict_int_str[2] = 2 + + with pytest.raises(TypeError, match="Expected 'int', but got 'str'"): + an_class.an_dict_int_str['3'] = 'three' + + def test__dict_with_complex_types(self): + if sys.version_info < (3, 10): + pytest.skip("Skipping test that doesn't work on 3.9 or lower") + + class An_Class(Type_Safe): + dict_str_dict_str_int: Dict[str, Dict[str, int]] + dict_str_list_int : Dict[str, List[int]] + dict_union_str_int : Dict[Union[str, int], str] + + an_class = An_Class() + + an_class.dict_str_dict_str_int['outer'] = {'inner': 42} # dict_str_dict_str_int -> Dict[str, Dict[str, int]] + with pytest.raises(TypeError, match="Expected 'int', but got 'str'"): + an_class.dict_str_dict_str_int['outer_fail'] = {'inner': 'bad'} + + an_class.dict_str_list_int['my_list'] = [1, 2, 3] # dict_str_list_int -> Dict[str, List[int]] + with pytest.raises(TypeError, match="Expected 'int', but got 'str'"): + an_class.dict_str_list_int['my_list2'] = [1, 'a', 3] + + an_class.dict_union_str_int['str_key'] = 'value' # dict_union_str_int -> Dict[Union[str, int], str] + an_class.dict_union_str_int[123] = 'value2' + with pytest.raises(TypeError, match="Expected 'str', but got 'int'"): + an_class.dict_union_str_int[456] = 789 + with pytest.raises(TypeError, match=re.escape("Expected 'Union[str, int]', but got 'dict'")): + an_class.dict_union_str_int[{}] = 'value' + + def test__dict_with_custom_type(self): + class CustomType(Type_Safe): + a: int + b: str + + class An_Class(Type_Safe): + an_dict_custom: Dict[str, CustomType] + + an_class = An_Class() + an_class.an_dict_custom['ok'] = CustomType(a=1, b='abc') + with pytest.raises(TypeError, match="Expected 'CustomType', but got 'dict'"): + an_class.an_dict_custom['fail'] = {'a': 2, 'b': 'def'} + + def test__dict_with_empty_collections(self): # Check that empty dict fields can be assigned without issues. + class An_Class(Type_Safe): + an_dict_of_dicts: Dict[str, Dict] + an_dict_of_lists: Dict[str, list] + + an_class = An_Class() + assert type(an_class.an_dict_of_dicts) is Type_Safe__Dict + assert type(an_class.an_dict_of_lists) is Type_Safe__Dict + + + an_class.an_dict_of_dicts['empty' ] = {} # Empty dictionary + an_class.an_dict_of_dicts['full' ] = {'a': 1} # Arbitrary dict (since no type parameters) + an_class.an_dict_of_lists['empty_list'] = [] # Empty list + an_class.an_dict_of_lists['mixed_list'] = [1, 'a', {}] # Arbitrary list + + def test__dict_with_mismatched_types(self): + class An_Class(Type_Safe): + an_dict_str_int: Dict[str, int] + + an_class = An_Class() + an_class.an_dict_str_int['ok'] = 123 # Valid assignment + + with pytest.raises(TypeError, match="Expected 'int', but got 'str'"): # Mismatched type for value + an_class.an_dict_str_int['fail'] = 'abc' + + with pytest.raises(TypeError, match="Expected 'str', but got 'int'"): # Mismatched type for key + an_class.an_dict_str_int[123] = 123 + + def test__dict_with_recursive_types(self): # Check a dictionary pointing to a forward reference of itself + class TreeNode(Type_Safe): + value: int + children: Optional[Dict[str, 'TreeNode']] # forward reference + + class An_Class(Type_Safe): + tree: TreeNode + + an_class = An_Class() + an_class.tree = TreeNode(value=1, children=None) # OK so far + + an_class.tree.children = Type_Safe__Dict(str, TreeNode) # Insert a valid child node + an_class.tree.children['child1'] = TreeNode(value=2, children=None) + + # Invalid child node (type mismatch) + with pytest.raises(TypeError, match="Expected 'TreeNode', but got 'int'"): + an_class.tree.children['child2'] = 42 + + def test__dict_with_multiple_generics(self): # Check nested generics in dict keys or values. + if sys.version_info < (3, 10): + pytest.skip("Skipping test that doesn't work on 3.9 or lower") + + class An_Class(Type_Safe): + dict_tuple_int_str: Dict[str, Tuple[int, str]] + + an_class = An_Class() + an_class.dict_tuple_int_str['combo'] = (1, 'a') + + with pytest.raises(TypeError, match="Expected 'str', but got 'int'"): + an_class.dict_tuple_int_str['combo'] = (1, 2) # second item must be str + + with pytest.raises(TypeError, match="Expected tuple of length 2, but got 3"): + an_class.dict_tuple_int_str['combo'] = (1, 'a', 3.0) + + def test__dict_with_any(self): + class An_Class(Type_Safe): + dict_with_any_value: Dict[str, Any] + + an_class = An_Class() + + + an_class.dict_with_any_value['int' ] = 1 # Any type is acceptable for the value + an_class.dict_with_any_value['str' ] = 'a' + an_class.dict_with_any_value['dict'] = {} + an_class.dict_with_any_value['list'] = [1, 2, 3] + + def test__dict_with_no_type(self): # Untyped dict: any key & any value are accepted. + class An_Class(Type_Safe): + an_dict_untyped: dict + + an_class = An_Class() + an_class.an_dict_untyped['int'] = 1 + an_class.an_dict_untyped[2] = 'some string' + an_class.an_dict_untyped[None] = [1, 2, 3] + + def test__dict_with_none(self): # Ensures that None is not acceptable unless optional is specified. + class An_Class(Type_Safe): + an_dict_str_int: Dict[str, int] + + an_class = An_Class() + + with pytest.raises(TypeError, match="Expected 'int', but got 'NoneType'"): + an_class.an_dict_str_int['fail'] = None + + def test__dict_with_nested_structures(self): + class An_Class(Type_Safe): + an_dict_str_list_int: Dict[str, List[int]] + + an_class = An_Class() + an_class.an_dict_str_list_int['numbers'] = [1, 2, 3] + + with pytest.raises(TypeError, match="Expected 'int', but got 'str'"): # Invalid: one element is a string + an_class.an_dict_str_list_int['bad_numbers'] = [1, 'b', 3] + + with pytest.raises(TypeError, match=r"Expected 'list\[int\]', but got 'int'"): # Invalid: the value is not a list at all + an_class.an_dict_str_list_int['not_a_list'] = 123 + + def test__dict_with_callable(self): + class An_Class(Type_Safe): + an_dict_callables: Dict[str, Callable[[int], str]] + + an_class = An_Class() + + def func(x: int) -> str: + return str(x) + + an_class.an_dict_callables['ok'] = func + + # Not a callable + with pytest.raises(TypeError, match=re.escape("Expected 'Callable[[], str]', but got 'int'")): + an_class.an_dict_callables['fail'] = 42 + + + def test__dict_with_forward_references(self): # Check that forward references (string-based) get enforced at runtime.""" + class An_Class(Type_Safe): + an_dict_str_self: Dict[str, 'An_Class'] + + an_class = An_Class() + # Valid + an_class.an_dict_str_self['me'] = An_Class() + + # Invalid + with pytest.raises(TypeError, match="Expected 'An_Class', but got 'int'"): + an_class.an_dict_str_self['fail'] = 123 + + def test__regression__nested_types__not_supported__in_dict(self): # Similar to the list test that uses forward references in nested structures + class An_Class(Type_Safe): + an_str: str + an_dict: Dict[str, 'An_Class'] + + an_class = An_Class() + an_class.an_str = "top-level" + assert type(an_class.an_dict) is Type_Safe__Dict + assert an_class.an_dict == {} + + # Valid usage + an_child = An_Class() + an_child.an_str = "child" + an_class.an_dict['child'] = an_child + + # Invalid usage + with pytest.raises(TypeError, match="Expected 'An_Class', but got 'str'"): + an_class.an_dict['bad_child'] = "some string" diff --git a/tests/unit/base_classes/test_Type_Safe__List.py b/tests/unit/base_classes/test_Type_Safe__List.py index 5bebb118..8e1d43e0 100644 --- a/tests/unit/base_classes/test_Type_Safe__List.py +++ b/tests/unit/base_classes/test_Type_Safe__List.py @@ -344,17 +344,6 @@ def invalid_func(x: str) -> int: # Invalid callable (wrong si an_class.an_list__callable.append(invalid_func) # BUG doesn't raise (i.e. at the moment we are not detecting the callable signature and return type) - def test__bug__type_safe_list_with_forward_references(self): - class An_Class(Type_Safe): - an_list__self_reference: List['An_Class'] - - an_class = An_Class() - an_class.an_list__self_reference.append(An_Class()) - - an_class.an_list__self_reference.append(1) # BUG , type safety not checked on forward references - # with pytest.raises(TypeError, match="Expected 'An_Class', but got 'int'"): - # an_class.an_list__self_reference.append(1) - # this test will cause the List[Union[Dict[str, int], List[str], int]] to be cached (inside python, which will impact the next two tests) def test__type_safe_list_with_complex_union_types(self): if sys.version_info < (3, 10): @@ -437,3 +426,32 @@ class An_Class(Type_Safe): #with pytest.raises(TypeError, match=re.escape("Expected 'Union[int, list[str], dict[str, int]]', but got 'dict'")): with pytest.raises(TypeError, match=re.escape("Invalid type for item: Expected 'Union[dict[str, int], list[str], int]', but got 'dict'")): an_class.an_list__mixed.append({'a': 'b'}) + + def test__regression__type_safe_list_with_forward_references(self): + class An_Class(Type_Safe): + an_list__self_reference: List['An_Class'] + + an_class = An_Class() + an_class.an_list__self_reference.append(An_Class()) + + #an_class.an_list__self_reference.append(1) # BUG , type safety not checked on forward references + with pytest.raises(TypeError, match="Expected 'An_Class', but got 'int'"): + an_class.an_list__self_reference.append(1) + + + def test__regression__nested_types__not_supported__in_list(self): + class An_Class(Type_Safe): + an_str : str + an_list : List['An_Class'] + + an_class = An_Class() + assert type(an_class.an_list) is Type_Safe__List + assert an_class.an_list == [] + + an_class_a = An_Class() + an_class.an_list.append(an_class_a) + with pytest.raises(TypeError, match="In Type_Safe__List: Invalid type for item: Expected 'An_Class', but got 'str'"): + an_class.an_list.append('b' ) # BUG: as above + + + diff --git a/tests/unit/base_classes/test_Type_Safe__bugs.py b/tests/unit/base_classes/test_Type_Safe__bugs.py index 8e106ce1..d7149224 100644 --- a/tests/unit/base_classes/test_Type_Safe__bugs.py +++ b/tests/unit/base_classes/test_Type_Safe__bugs.py @@ -1,39 +1,22 @@ import sys - import pytest -from typing import Optional, Union, Dict, List +from typing import Optional, Union, Dict from unittest import TestCase from osbot_utils.base_classes.Type_Safe import Type_Safe from osbot_utils.base_classes.Kwargs_To_Self import Kwargs_To_Self -from osbot_utils.base_classes.Type_Safe__List import Type_Safe__List -from osbot_utils.helpers.Random_Guid import Random_Guid -from osbot_utils.utils.Dev import pprint -from osbot_utils.utils.Objects import __ - +from osbot_utils.base_classes.Type_Safe__Dict import Type_Safe__Dict class test_Type_Safe__bugs(TestCase): - def test__bug__nested_dict_serialisations_dont_work(self): - if sys.version_info < (3, 9): - pytest.skip("this doesn't work on 3.8 or lower") - class An_Class_1(Type_Safe): - dict_5: Dict[Random_Guid, dict[Random_Guid, Random_Guid]] - json_data_1 = { 'dict_5': {Random_Guid(): { Random_Guid():Random_Guid(), - Random_Guid():Random_Guid(), - 'no-guid-1': 'no-guid-2'}}} - assert An_Class_1().from_json(json_data_1).json() == json_data_1 # BUG: should had raised exception on 'no-guid-1': 'no-guid-2' - - - def test__bug__ctor__does_not_recreate__Dict__objects(self): class An_Class_1(Type_Safe): an_dict : Dict[str,int] json_data_1 = {'an_dict': {'key_1': 42}} - an_class_1 = An_Class_1.from_json(json_data_1) + an_class_1 = An_Class_1.from_json(json_data_1) - assert type(an_class_1.an_dict) is dict + assert type(an_class_1.an_dict) is Type_Safe__Dict # Fixed: BUG this should be Type_Safe__Dict assert an_class_1.an_dict == {'key_1': 42} class An_Class_2_B(Type_Safe): @@ -45,12 +28,10 @@ class An_Class_2_A(Type_Safe): json_data_2 = {'an_dict' : {'key_1': {'an_str': 'value_1'}}, 'an_class_2_b': {'an_str': 'value_1'}} - print() an_class_2 = An_Class_2_A.from_json(json_data_2) assert an_class_2.json() == json_data_2 - assert type(an_class_2.an_dict ) is dict - + assert type(an_class_2.an_dict ) is Type_Safe__Dict # Fixed BUG this should be Type_Safe__Dict assert type(an_class_2.an_dict['key_1'] ) is An_Class_2_B # Fixed: BUG: this should be An_Class_2_B not an dict # todo fix the scenario where we try to create a new object from a dict value using the ctor instead of the from_json method @@ -80,21 +61,6 @@ def __init__(self): # this will make the __annotations__ to assert an_class.an_str == 'new_value' assert an_class.an_bool == False - def test__bug__type_safe_is_not_enforced_on_dict_and_Dict(self): - class An_Class(Type_Safe): - an_dict : Dict[str,int] - - an_class = An_Class() - - assert An_Class.__annotations__ == {'an_dict': Dict[str, int]} - assert an_class.__locals__() == {'an_dict': {}} - assert type(an_class.an_dict) is dict # BUG: this should be Type_Safe__Dict # todo: see if there is a better way to do this, without needing to replace the Dict object with Type_Safe__Dict (although this technique worked ok for Type_Safe__List) - an_class.an_dict[42] = 'an_str' # BUG: this should not be allowed - # - using key 42 should have raised exception (it is an int instead of a str) - # - using value 'an_str' should have raised exception (it is a str instead of an int) - - - diff --git a/tests/unit/base_classes/test_Type_Safe__regression.py b/tests/unit/base_classes/test_Type_Safe__regression.py index 6294cb3e..ba062932 100644 --- a/tests/unit/base_classes/test_Type_Safe__regression.py +++ b/tests/unit/base_classes/test_Type_Safe__regression.py @@ -6,6 +6,7 @@ from unittest.mock import patch from osbot_utils.base_classes.Kwargs_To_Self import Kwargs_To_Self from osbot_utils.base_classes.Type_Safe import Type_Safe +from osbot_utils.base_classes.Type_Safe__Dict import Type_Safe__Dict from osbot_utils.base_classes.Type_Safe__List import Type_Safe__List from osbot_utils.decorators.methods.cache_on_self import cache_on_self from osbot_utils.graphs.mermaid.Mermaid import Mermaid @@ -20,6 +21,88 @@ class test_Type_Safe__regression(TestCase): + def test__regression__dict_dont_support_type_checks(self): + + class An_Class_2(Type_Safe): + an_dict: Dict[str,str] + + an_class_2 = An_Class_2() + assert type(an_class_2.an_dict) is Type_Safe__Dict # Fixed: BUG this should be Type_Safe__Dict + with pytest.raises(TypeError, match="Expected 'str', but got 'int'"): + an_class_2.an_dict['key_1'] = 123 # Fixed: BUG this should had raise an exception + #assert an_class_2.an_dict == {'key_1': 123} # BUG: this should not have been assigned + assert an_class_2.an_dict == {} # Fixed + + def test__regression__dicts_dont_support_type_forward(self): + class An_Class(Type_Safe): + an_dict: Dict[str, 'An_Class'] + + an_class = An_Class() + an_class_a = An_Class() + assert an_class.an_dict == {} + with pytest.raises(TypeError, match="Expected 'An_Class', but got 'str'"): + an_class.an_dict['an_str' ] = 'bb' # Fixed: BUG: exception should have raised + an_class.an_dict['an_class'] = an_class_a + assert an_class.an_dict == dict(#an_str='bb' , # Fixed: BUG: an_str should not have been assigned + an_class=an_class_a) + + + def test__regression__nested_dict_serialisations_dont_work(self): + if sys.version_info < (3, 9): + pytest.skip("this doesn't work on 3.8 or lower") + class An_Class_1(Type_Safe): + dict_5: Dict[Random_Guid, dict[Random_Guid, Random_Guid]] + + json_data_1 = { 'dict_5': {Random_Guid(): { Random_Guid():Random_Guid() , + Random_Guid():Random_Guid() , + 'no-guid-1': 'no-guid-2' }}} + + json_data_2 = { 'dict_5': {Random_Guid(): { Random_Guid():Random_Guid() , + Random_Guid():Random_Guid() }}} + + with pytest.raises(TypeError, match="In dict key 'no-guid-1': Expected 'Random_Guid', but got 'str'"): + assert An_Class_1().from_json(json_data_1).json() == json_data_1 # BUG: should had raised exception on 'no-guid-1': 'no-guid-2' + + assert An_Class_1().from_json(json_data_2).json() == json_data_2 + + def test__regression__type_safe_is_not_enforced_on_dict_and_Dict(self): + class An_Class(Type_Safe): + an_dict : Dict[str,int] + + an_class = An_Class() + + assert An_Class.__annotations__ == {'an_dict': Dict[str, int]} + assert an_class.__locals__() == {'an_dict': {}} + assert type(an_class.an_dict) is Type_Safe__Dict # Fixed: BUG: this should be Type_Safe__Dict + with pytest.raises(TypeError, match="Expected 'str', but got 'int'"): + an_class.an_dict[42] = 'an_str' # Fixed: BUG: this should not be allowed + # - using key 42 should have raised exception (it is an int instead of a str) + # - using value 'an_str' should have raised exception (it is a str instead of an int) + + def test__regression__nested_types__not_supported(self): + class An_Class(Type_Safe): + an_class : 'An_Class' + + an_class = An_Class() + error_message_1 = "Invalid type for attribute 'an_class'. Expected 'An_Class' but got '.An_Class'>'" + + an_class.an_class = An_Class() + + # with pytest.raises(ValueError, match=error_message_1): + # an_class.an_class = An_Class() # BUG: should NOT have raised exception here + + assert type(an_class.an_class ) is An_Class + assert type(an_class.an_class.an_class) is type(None) + an_class.an_class = An_Class() # FIXED: this now works + assert type(an_class.an_class) is An_Class + assert type(an_class.an_class.an_class) is type(None) + + print(an_class.json()) + + error_message_2 = "Invalid type for attribute 'an_class'. Expected 'An_Class' but got '" + with pytest.raises(ValueError, match=error_message_2): + an_class.an_class = 'a' # BUG: wrong exception + def test__regression__list_from_json_not_enforcing_type_safety(self): class An_Class__Item(Type_Safe): an_str: str diff --git a/tests/unit/helpers/test_Xml_To_Dict.py b/tests/unit/helpers/test_Xml_To_Dict.py new file mode 100644 index 00000000..a23e8d59 --- /dev/null +++ b/tests/unit/helpers/test_Xml_To_Dict.py @@ -0,0 +1,210 @@ +from unittest import TestCase +from xml.etree.ElementTree import Element +from osbot_utils.helpers.Xml_To_Dict import Xml_To_Dict + + +class test_Xml_To_Dict(TestCase): + @classmethod + def setUpClass(cls): + cls.test_xml_data = TEST_DATA__XML_1 + cls.xml_to_dict = Xml_To_Dict(xml_data=cls.test_xml_data).setup().parse() + + def test_setup(self): + with self.xml_to_dict as _: + assert _.xml_data == self.test_xml_data + assert type(_.root) == Element + assert _.root.attrib == {'version': '2.0'} + assert _.namespaces == {'sy': 'http://purl.org/rss/1.0/modules/syndication/'} + + + def test_parse(self): + with self.xml_to_dict as _: + expected_dict = {'channel': {'description' : 'Test Description Feed' , + 'item' : { 'author' : 'info@test-feed.com (Test Author)' , + 'description' : 'Test Description' , + 'enclosure' : { 'length': '12216320' , + 'type' : 'image/jpeg' , + 'url' : 'https://example.com/image.jpg' }, + 'guid' : 'https://test-feed.com/2024/12/test-article.html' , + 'link' : 'https://test-feed.com/2024/12/test-article.html' , + 'pubDate' : 'Wed, 04 Dec 2024 22:53:00 +0530' , + 'title' : 'Test Article' }, + 'language' : 'en-us' , + 'lastBuildDate' : 'Thu, 05 Dec 2024 01:33:01 +0530' , + 'link' : 'https://test-feed.com' , + 'title' : 'Test Feed' , + 'updateFrequency': '1' , + 'updatePeriod' : 'hourly' }, + 'version': '2.0' } + assert _.xml_dict == expected_dict # BUG data is missing + + def test_load_namespaces(self): + xml_string = """ + + + Sample Security News + + """ + + # Parse the XML + with Xml_To_Dict(xml_data=xml_string).setup().parse() as _: + assert _.root.attrib == {'version': '2.0'} + assert _.namespaces == { 'atom' : 'http://www.w3.org/2005/Atom' , + 'content': 'http://purl.org/rss/1.0/modules/content/' , + 'dc' : 'http://purl.org/dc/elements/1.1/' , + 'slash' : 'http://purl.org/rss/1.0/modules/slash/' , + 'sy' : 'http://purl.org/rss/1.0/modules/syndication/', + 'wfw' : 'http://wellformedweb.org/CommentAPI/' } + + def test_basic_parsing(self): + with Xml_To_Dict(xml_data=TEST_DATA__XML_BASIC).setup().parse() as _: + assert _.xml_dict == {'child': 'Simple Text'} + + def test_attributes(self): + with Xml_To_Dict(xml_data=TEST_DATA__XML_ATTRIBUTES).setup().parse() as _: + expected = { + 'attr1': 'value1', + 'attr2': 'value2', + 'child': {'prop': 'test', '_text': 'Child Text'} + } + assert _.xml_dict == expected + + def test_multiple_items(self): + with Xml_To_Dict(xml_data=TEST_DATA__XML_MULTI_ITEMS).setup().parse() as _: + expected = { + 'item': ['First', 'Second', 'Third'] + } + assert _.xml_dict == expected + + def test_namespaces(self): + with Xml_To_Dict(xml_data=TEST_DATA__XML_NAMESPACES).setup().parse() as _: + assert _.namespaces == { + 'ns1': 'http://example.com/ns1', + 'ns2': 'http://example.com/ns2' + } + assert _.xml_dict == { + 'element': ['NS1 Content', 'NS2 Content'] + } + + def test_mixed_content(self): + with Xml_To_Dict(xml_data=TEST_DATA__XML_MIXED_CONTENT).setup().parse() as _: + expected = { + '_text': 'Text Content', #'Text Content More Text', + 'child': 'Child Text' + } + assert _.xml_dict == expected + + def test_cdata(self): + with Xml_To_Dict(xml_data=TEST_DATA__XML_CDATA).setup().parse() as _: + expected = { + 'description': 'Special & content' + } + assert _.xml_dict == expected + + def test_empty_elements(self): + with Xml_To_Dict(xml_data=TEST_DATA__XML_EMPTY).setup().parse() as _: + expected = { + 'empty': '', + 'self-closing': '' + } + assert _.xml_dict == expected + + def test_invalid_xml(self): + with self.assertRaises(ValueError) as context: + Xml_To_Dict(xml_data=TEST_DATA__XML_INVALID).setup() + assert "Invalid XML:" in str(context.exception) + + def test_edge_cases(self): + # Test None XML data + with self.assertRaises(ValueError): # Type_Safe will raise this + Xml_To_Dict().setup() + + # Test empty XML string + with self.assertRaises(ValueError): + Xml_To_Dict(xml_data='').setup() + + # Test XML with only whitespace + with self.assertRaises(ValueError): + Xml_To_Dict(xml_data=' \n ').setup() + + def test_type_checks(self): + xml_to_dict = Xml_To_Dict(xml_data=TEST_DATA__XML_BASIC).setup().parse() + assert isinstance(xml_to_dict.xml_data, str) + assert isinstance(xml_to_dict.root, Element) + assert isinstance(xml_to_dict.namespaces, dict) + assert isinstance(xml_to_dict.xml_dict, dict) + +TEST_DATA__XML_1 = ''' + + + Test Feed + https://test-feed.com + Test Description Feed + en-us + Thu, 05 Dec 2024 01:33:01 +0530 + hourly + 1 + + Test Article + + https://test-feed.com/2024/12/test-article.html + https://test-feed.com/2024/12/test-article.html + Wed, 04 Dec 2024 22:53:00 +0530 + info@test-feed.com (Test Author) + + + + ''' + +TEST_DATA__XML_BASIC = ''' + + Simple Text +''' + +TEST_DATA__XML_ATTRIBUTES = ''' + + Child Text +''' + +TEST_DATA__XML_MULTI_ITEMS = ''' + + First + Second + Third +''' + +TEST_DATA__XML_NAMESPACES = ''' + + NS1 Content + NS2 Content +''' + +TEST_DATA__XML_MIXED_CONTENT = ''' + + Text Content + Child Text + More Text +''' + +TEST_DATA__XML_CDATA = ''' + + & content]]> +''' + +TEST_DATA__XML_EMPTY = ''' + + + +''' + +TEST_DATA__XML_INVALID = ''' + + +''' \ No newline at end of file From e676ac3431226483b72766a9ff661a933b0156aa Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Dec 2024 16:33:00 +0000 Subject: [PATCH 2/4] Update release badge and version file --- README.md | 2 +- osbot_utils/version | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c13b0273..d1263df0 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-v1.88.0-blue) +![Current Release](https://img.shields.io/badge/release-v1.88.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/version b/osbot_utils/version index 95cc5bbd..4e50ce4c 100644 --- a/osbot_utils/version +++ b/osbot_utils/version @@ -1 +1 @@ -v1.88.0 +v1.88.1 diff --git a/pyproject.toml b/pyproject.toml index fa2f7299..a6296a3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "osbot_utils" -version = "v1.88.0" +version = "v1.88.1" description = "OWASP Security Bot - Utils" authors = ["Dinis Cruz "] license = "MIT" From 5dd0d092e3f2a6282f9fb6f87289fb3a45429acd Mon Sep 17 00:00:00 2001 From: Dinis Cruz Date: Thu, 26 Dec 2024 17:55:43 +0000 Subject: [PATCH 3/4] added more focused bug test --- .../unit/base_classes/test_Type_Safe__bugs.py | 36 +++++++++++-------- .../test_Type_Safe__regression.py | 27 ++++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/tests/unit/base_classes/test_Type_Safe__bugs.py b/tests/unit/base_classes/test_Type_Safe__bugs.py index d7149224..09b6e2dd 100644 --- a/tests/unit/base_classes/test_Type_Safe__bugs.py +++ b/tests/unit/base_classes/test_Type_Safe__bugs.py @@ -4,21 +4,29 @@ from unittest import TestCase from osbot_utils.base_classes.Type_Safe import Type_Safe from osbot_utils.base_classes.Kwargs_To_Self import Kwargs_To_Self -from osbot_utils.base_classes.Type_Safe__Dict import Type_Safe__Dict + class test_Type_Safe__bugs(TestCase): - def test__bug__ctor__does_not_recreate__Dict__objects(self): + def test__bug__in__convert_dict_to_value_from_obj_annotation(self): + class An_Class_2_B(Type_Safe): + an_str: str + + class An_Class_2_A(Type_Safe): + an_dict : Dict[str,An_Class_2_B] - class An_Class_1(Type_Safe): - an_dict : Dict[str,int] + an_class = An_Class_2_A() - json_data_1 = {'an_dict': {'key_1': 42}} - an_class_1 = An_Class_1.from_json(json_data_1) + target = an_class + attr_name = 'an_dict' + value = {'key_1': {'an_str': 'value_1'}} + from osbot_utils.utils.Objects import convert_dict_to_value_from_obj_annotation + converted_value = convert_dict_to_value_from_obj_annotation(target, attr_name, value) - assert type(an_class_1.an_dict) is Type_Safe__Dict # Fixed: BUG this should be Type_Safe__Dict - assert an_class_1.an_dict == {'key_1': 42} + assert converted_value == value + assert type(converted_value['key_1']) is dict # BUG: this should be An_Class_2_B + def test__bug__ctor__does_not_recreate__Dict__objects(self): class An_Class_2_B(Type_Safe): an_str: str @@ -28,17 +36,15 @@ class An_Class_2_A(Type_Safe): json_data_2 = {'an_dict' : {'key_1': {'an_str': 'value_1'}}, 'an_class_2_b': {'an_str': 'value_1'}} - an_class_2 = An_Class_2_A.from_json(json_data_2) - - assert an_class_2.json() == json_data_2 - assert type(an_class_2.an_dict ) is Type_Safe__Dict # Fixed BUG this should be Type_Safe__Dict - assert type(an_class_2.an_dict['key_1'] ) is An_Class_2_B # Fixed: BUG: this should be An_Class_2_B not an dict # todo fix the scenario where we try to create a new object from a dict value using the ctor instead of the from_json method + an_class = An_Class_2_A(**json_data_2) + assert type(an_class.an_dict) is dict # BUG should be Type_Safe__Dict + assert type(An_Class_2_A(**json_data_2).an_dict['key_1']) is dict assert type(An_Class_2_A(**json_data_2).an_dict['key_1']) is dict # BUG: this should be An_Class_2_B - assert type(An_Class_2_A(**json_data_2).an_class_2_b ) is An_Class_2_B # when not using Dict[str,An_Class_2_B] the object is created correctly - + #assert type(An_Class_2_A(**json_data_2).an_class_2_b ) is An_Class_2_B # when not using Dict[str,An_Class_2_B] the object is created correctly + assert An_Class_2_A(**json_data_2).json() == json_data_2 # todo: figure out why when this test was runs will all the others tests test_Type_Safe tests, it doesn't hit the lines in __setattr__ (as proven by the lack of code coverage) diff --git a/tests/unit/base_classes/test_Type_Safe__regression.py b/tests/unit/base_classes/test_Type_Safe__regression.py index ba062932..2310b339 100644 --- a/tests/unit/base_classes/test_Type_Safe__regression.py +++ b/tests/unit/base_classes/test_Type_Safe__regression.py @@ -21,6 +21,33 @@ class test_Type_Safe__regression(TestCase): + def test__regression__from_json__does_not_recreate__Dict__objects(self): + + class An_Class_1(Type_Safe): + an_dict : Dict[str,int] + + json_data_1 = {'an_dict': {'key_1': 42}} + an_class_1 = An_Class_1.from_json(json_data_1) + + assert type(an_class_1.an_dict) is Type_Safe__Dict # Fixed: BUG this should be Type_Safe__Dict + assert an_class_1.an_dict == {'key_1': 42} + + class An_Class_2_B(Type_Safe): + an_str: str + + class An_Class_2_A(Type_Safe): + an_dict : Dict[str,An_Class_2_B] + an_class_2_b : An_Class_2_B + + json_data_2 = {'an_dict' : {'key_1': {'an_str': 'value_1'}}, + 'an_class_2_b': {'an_str': 'value_1'}} + an_class_2 = An_Class_2_A.from_json(json_data_2) + + assert an_class_2.json() == json_data_2 + assert type(an_class_2.an_dict ) is Type_Safe__Dict # Fixed BUG this should be Type_Safe__Dict + assert type(an_class_2.an_dict['key_1'] ) is An_Class_2_B # Fixed: BUG: this should be An_Class_2_B not an dict + + def test__regression__dict_dont_support_type_checks(self): class An_Class_2(Type_Safe): From 6d8f975f7d66a411d2f3eb09fbfc3c8ad0e22727 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Dec 2024 18:32:11 +0000 Subject: [PATCH 4/4] Update release badge and version file --- README.md | 2 +- osbot_utils/version | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d1263df0..9d93b17a 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-v1.88.1-blue) +![Current Release](https://img.shields.io/badge/release-v1.88.2-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/version b/osbot_utils/version index 4e50ce4c..e08bfb18 100644 --- a/osbot_utils/version +++ b/osbot_utils/version @@ -1 +1 @@ -v1.88.1 +v1.88.2 diff --git a/pyproject.toml b/pyproject.toml index a6296a3f..4bb16c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "osbot_utils" -version = "v1.88.1" +version = "v1.88.2" description = "OWASP Security Bot - Utils" authors = ["Dinis Cruz "] license = "MIT"