-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
added type_safe method type_safe_property
- Loading branch information
Showing
9 changed files
with
203 additions
and
0 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
from typing import TypeVar, Type, Optional | ||
|
||
T = TypeVar('T') | ||
|
||
def type_safe_property(target_path: str, target_name: str, expected_type: Optional[Type[T]] = None) -> property: # Creates a type-safe property that delegates get/set operations to a nested data object | ||
|
||
def getter(self) -> T: | ||
target = self | ||
for part in target_path.split('.'): | ||
target = getattr(target, part) | ||
|
||
value = getattr(target, target_name) | ||
|
||
if expected_type and value is not None: | ||
if not isinstance(value, expected_type): | ||
raise TypeError(f"Property '{target_name}' returned value of type {type(value)}, expected {expected_type}") | ||
return value | ||
|
||
def setter(self, value: T) -> None: | ||
if expected_type and value is not None: | ||
if not isinstance(value, expected_type): | ||
raise TypeError(f"Cannot set property '{target_name}' with value of type {type(value)}, expected {expected_type}") | ||
|
||
target = self | ||
for part in target_path.split('.'): | ||
target = getattr(target, part) | ||
if hasattr(target, target_name) is False: | ||
raise AttributeError(f"Target path '{target_path}' does not have an attribute '{target_name}'") | ||
setattr(target, target_name, value) | ||
|
||
return property(getter, setter) | ||
|
||
|
||
wire_as_property = type_safe_property | ||
bind_as_property = type_safe_property | ||
set_as_property = type_safe_property |
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
167 changes: 167 additions & 0 deletions
167
tests/unit/type_safe/methods/test_type_safe_property.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
import re | ||
import pytest | ||
from unittest import TestCase | ||
from typing import Optional, Dict | ||
from osbot_utils.type_safe.Type_Safe import Type_Safe | ||
from osbot_utils.type_safe.methods.type_safe_property import type_safe_property | ||
|
||
|
||
class test_Type_Safe__Property(TestCase): | ||
|
||
def setUp(self): # Initialize test data | ||
class NestedData(Type_Safe): | ||
name : str | ||
age : int | ||
active : bool | ||
tags : Optional[Dict[str, str]] | ||
|
||
class InnerClass(Type_Safe): | ||
data: NestedData | ||
|
||
class TestClass(Type_Safe): | ||
inner: InnerClass | ||
|
||
name = type_safe_property('inner.data', 'name' , str ) | ||
age = type_safe_property('inner.data', 'age' , int ) | ||
active = type_safe_property('inner.data', 'active', bool) | ||
tags = type_safe_property('inner.data', 'tags' , dict) | ||
|
||
self.NestedData = NestedData | ||
self.InnerClass = InnerClass | ||
self.TestClass = TestClass | ||
|
||
def test_basic_property_access(self): # Tests basic getter and setter functionality | ||
test_obj = self.TestClass() | ||
test_obj.inner = self.InnerClass() | ||
test_obj.inner.data = self.NestedData() | ||
|
||
test_obj.name = "test" | ||
assert test_obj.name == "test" | ||
assert test_obj.inner.data.name == "test" | ||
|
||
test_obj.age = 25 | ||
assert test_obj.age == 25 | ||
assert test_obj.inner.data.age == 25 | ||
|
||
def test_type_validation(self): # Tests type safety validation | ||
test_obj = self.TestClass() | ||
test_obj.inner = self.InnerClass() | ||
test_obj.inner.data = self.NestedData() | ||
|
||
# Test valid assignments | ||
test_obj.name = "valid" | ||
test_obj.age = 30 | ||
test_obj.active = True | ||
test_obj.tags = {"key": "value"} | ||
|
||
# Test invalid assignments | ||
with self.assertRaises(TypeError) as context: | ||
test_obj.name = 123 | ||
assert "Cannot set property 'name' with value of type <class 'int'>, expected <class 'str'>" in str(context.exception) | ||
|
||
with self.assertRaises(TypeError) as context: | ||
test_obj.age = "not an int" | ||
assert "Cannot set property 'age' with value of type <class 'str'>, expected <class 'int'>" in str(context.exception) | ||
|
||
with self.assertRaises(TypeError) as context: | ||
test_obj.active = "not a bool" | ||
assert "Cannot set property 'active' with value of type <class 'str'>, expected <class 'bool'>" in str(context.exception) | ||
|
||
def test_none_values(self): # Tests handling of None values | ||
test_obj = self.TestClass() | ||
test_obj.inner = self.InnerClass() | ||
test_obj.inner.data = self.NestedData() | ||
|
||
# Test setting None on optional field | ||
test_obj.tags = None | ||
assert test_obj.tags is None | ||
|
||
# Test setting None on required field | ||
with pytest.raises(ValueError, match=re.escape("Can't set None, to a variable that is already set. Invalid type for attribute 'name'. Expected '<class 'str'>' but got '<class 'NoneType'>'")): | ||
test_obj.name = None | ||
|
||
|
||
def test_invalid_paths(self): # Tests error handling for invalid paths | ||
class Data_Class(Type_Safe): | ||
pass | ||
|
||
class Inner_Class(Type_Safe): | ||
data: Data_Class | ||
|
||
class Bad_Class(Type_Safe): | ||
inner: Inner_Class | ||
|
||
bad_path = type_safe_property('wrong.path' , 'name' , str) # property with non-existent path | ||
bad_start = type_safe_property('missing.data' , 'name' , str) # property with invalid first part | ||
bad_middle = type_safe_property('inner.missing' , 'name' , str) # property with invalid second part | ||
bad_end = type_safe_property('inner.data' , 'missing', str) # property with invalid final attribute | ||
|
||
test_obj = Bad_Class() | ||
|
||
with pytest.raises(AttributeError, match="'Bad_Class' object has no attribute 'wrong'"): | ||
test_obj.bad_path = "test" | ||
|
||
with pytest.raises(AttributeError, match="'Bad_Class' object has no attribute 'missing'"): | ||
test_obj.bad_start = "test" | ||
|
||
with pytest.raises(AttributeError, match="'Inner_Class' object has no attribute 'missing'"): | ||
test_obj.bad_middle = "test" | ||
|
||
with pytest.raises(AttributeError, match="Target path 'inner.data' does not have an attribute 'missing'"): | ||
test_obj.bad_end = "test" | ||
|
||
|
||
def test_property_isolation(self): # Tests property isolation between instances | ||
obj1 = self.TestClass() | ||
obj1.inner = self.InnerClass() | ||
obj1.inner.data = self.NestedData() | ||
|
||
obj2 = self.TestClass() | ||
obj2.inner = self.InnerClass() | ||
obj2.inner.data = self.NestedData() | ||
|
||
obj1.name = "obj1" | ||
obj2.name = "obj2" | ||
|
||
assert obj1.name == "obj1" | ||
assert obj2.name == "obj2" | ||
|
||
def test_inheritance(self): # Tests inheritance behavior | ||
class ChildClass(self.TestClass): | ||
extra = type_safe_property('inner.data', 'tags', dict) | ||
|
||
child = ChildClass() | ||
child.inner = self.InnerClass() | ||
child.inner.data = self.NestedData() | ||
|
||
# Test inherited property | ||
child.name = "test" | ||
assert child.name == "test" | ||
|
||
# Test new property | ||
child.extra = {"new": "value"} | ||
assert child.extra == {"new": "value"} | ||
|
||
# Verify parent class properties still work | ||
parent = self.TestClass() | ||
parent.inner = self.InnerClass() | ||
parent.inner.data = self.NestedData() | ||
parent.name = "parent" | ||
assert parent.name == "parent" | ||
|
||
def test_edge_cases(self): # Tests edge cases and boundary conditions | ||
test_obj = self.TestClass() | ||
test_obj.inner = self.InnerClass() | ||
test_obj.inner.data = self.NestedData() | ||
|
||
# Test empty string | ||
test_obj.name = "" | ||
assert test_obj.name == "" | ||
|
||
# Test zero | ||
test_obj.age = 0 | ||
assert test_obj.age == 0 | ||
|
||
# Test empty dict | ||
test_obj.tags = {} | ||
assert test_obj.tags == {} |