Skip to content

Commit 5cae915

Browse files
committed
breaking change: __.diff() now returns an __() object
while doing the change above, made a number of improvements to its logic (which as seen by the tests added, made it much more useful)
1 parent a581efd commit 5cae915

File tree

3 files changed

+259
-26
lines changed

3 files changed

+259
-26
lines changed

osbot_utils/testing/__.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ def __eq__(self, other):
2828
continue
2929

3030
# Handle comparison operators
31-
if isinstance(other_val, tuple) and len(other_val) >= 2:
31+
# if isinstance(other_val, tuple) and len(other_val) >= 2:
32+
# op = other_val[0]
33+
if (isinstance(other_val, tuple) and len(other_val) >= 2 and
34+
not isinstance(self_val, tuple)): # ← ADD THIS CHECK
3235
op = other_val[0]
3336
try:
3437
if op == 'gt' and not (self_val > other_val[1]):
@@ -69,6 +72,12 @@ def contains(self, other=None, **kwargs):
6972
for key, expected_value in other_dict.items():
7073
if expected_value is __SKIP__: # Skip this field
7174
continue
75+
76+
if expected_value is __MISSING__:
77+
if hasattr(self, key):
78+
return False # Field exists but expected missing
79+
continue # Field missing as expected
80+
7281
if not hasattr(self, key):
7382
return False
7483
actual_value = getattr(self, key)
@@ -108,10 +117,16 @@ def diff(self, other): # Return differences between objects for
108117
self_val = getattr(self, key, __MISSING__)
109118
other_val = getattr(other, key, __MISSING__) if hasattr(other, '__dict__') else other.get(key, __MISSING__) if isinstance(other, dict) else __MISSING__
110119

120+
if self_val is __SKIP__ or other_val is __SKIP__:
121+
continue
122+
111123
if self_val != other_val:
112124
differences[key] = {'actual': self_val, 'expected': other_val}
113125

114-
return differences if differences else None
126+
#return differences if differences else None
127+
from osbot_utils.testing.__helpers import dict_to_obj
128+
return dict_to_obj(differences)
129+
115130

116131
def excluding(self, *fields): # Return copy without specified fields for comparison"
117132
result = __(**self.__dict__)

tests/unit/testing/test__.py

Lines changed: 240 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -96,33 +96,36 @@ def test_contains__with_invalid_input(self):
9696
def test_diff(self): # Test diff method for debugging
9797
with __(a=1, b='test', c=3.14) as obj1:
9898
with __(a=1, b='test', c=3.14) as obj2:
99-
assert obj1.diff(obj2) is None # No differences
99+
assert obj1.diff(obj2) == __() # No differences
100100

101101
with __(a=2, b='test', c=3.14) as obj3:
102102
diff = obj1.diff(obj3)
103-
assert diff == {'a': {'actual': 1, 'expected': 2}} # Single difference
103+
assert diff == __(a=__(actual=1, expected=2))
104+
#assert diff == {'a': {'actual': 1, 'expected': 2}} # Single difference
104105

105106
with __(a=2, b='changed', d=4) as obj4:
106107
diff = obj1.diff(obj4)
107-
assert 'a' in diff # Changed field
108-
assert 'b' in diff # Another changed field
109-
assert 'c' in diff # Missing in obj4
110-
assert 'd' in diff # Extra in obj4
108+
assert diff == __(a=__(actual=1, expected=2),
109+
b=__(actual='test', expected='changed'),
110+
d=__(actual=__MISSING__, expected=4),
111+
c=__(actual=3.14, expected=__MISSING__))
112+
# assert 'a' in diff # Changed field
113+
# assert 'b' in diff # Another changed field
114+
# assert 'c' in diff # Missing in obj4
115+
# assert 'd' in diff # Extra in obj4
111116

112-
def test_diff__with_dict(self): # Test diff with dict comparison
113-
with __(a=1, b=2) as _:
114-
diff = _.diff({'a': 1, 'b': 3})
115-
assert diff == {'b': {'actual': 2, 'expected': 3}} # Dict comparison works
116117

117118
def test_diff__with_missing_fields(self): # Test diff with missing fields
118119
with __(a=1, b=2) as obj1:
119120
with __(a=1) as obj2:
120121
diff = obj1.diff(obj2)
121-
assert diff == {'b': {'actual': 2, 'expected': __MISSING__}} # Missing marked correctly
122+
assert diff == __(b=__(actual=2, expected=__MISSING__))
123+
#assert diff == {'b': {'actual': 2, 'expected': __MISSING__}} # Missing marked correctly
122124

123125
with __(a=1, b=2, c=3) as obj3:
124126
diff = obj1.diff(obj3)
125-
assert diff == {'c': {'actual': __MISSING__, 'expected': 3}} # Extra field detected
127+
assert diff == __(c=__(actual=__MISSING__, expected=3))
128+
#assert diff == {'c': {'actual': __MISSING__, 'expected': 3}} # Extra field detected
126129

127130
def test_excluding(self): # Test excluding fields from comparison
128131
with __(id='123', name='Test', created_at='2024-01-01', updated_at='2024-01-02') as _:
@@ -570,7 +573,9 @@ def test__diff_with_operators(self):
570573
diff = actual.diff(expected)
571574
# diff will show the actual values vs the operator tuples
572575
assert diff is not None
573-
assert 'score' in diff or 'duration' in diff # At least one field differs
576+
assert diff == __(duration=__(actual=0.234, expected=('lt', 0.1)),
577+
score=__(actual=85, expected=('gt', 90)))
578+
#assert 'score' in diff or 'duration' in diff # At least one field differs
574579

575580

576581
# Test operators work with contains() method
@@ -809,15 +814,19 @@ def test__operators_mixed_with_skip_in_nested(self):
809814

810815
def test__diff_shows_operator_tuples(self): # Test diff reveals operator structure
811816
with __(score=85, duration=0.234) as actual:
812-
expected = __(score=__GREATER_THAN__(90), duration=__LESS_THAN__(0.1))
817+
expected_1 = __(score=90, duration=0.1)
813818

814-
diff = actual.diff(expected)
819+
diff_1 = actual.diff(expected_1)
820+
assert type(diff_1) is __
821+
assert diff_1 == __(duration = __(actual=0.234, expected=0.1),
822+
score = __(actual=85 , expected=90)) # this passes ok
823+
824+
expected_2 = __(score=__GREATER_THAN__(90), duration=__LESS_THAN__(0.1))
825+
diff_2 = actual.diff(expected_2)
826+
827+
assert diff_2 == __(duration = __(actual=0.234, expected=__LESS_THAN__(0.1)),
828+
score = __(actual=85 , expected=__GREATER_THAN__(90))) # this fails here, but the results are the same
815829

816-
# diff should show actual values vs operator tuples
817-
assert diff is not None
818-
assert 'score' in diff
819-
assert diff['score']['actual'] == 85
820-
assert diff['score']['expected'] == ('gt', 90) # Shows operator structure
821830

822831
def test__diff_with_close_to_tolerance(self): # Test diff shows CLOSE_TO details
823832
with __(value=1.0) as actual:
@@ -826,7 +835,8 @@ def test__diff_with_close_to_tolerance(self):
826835
diff = actual.diff(expected)
827836

828837
# diff shows the operator tuple with tolerance
829-
assert diff['value']['expected'] == ('close_to', 2.0, 0.5)
838+
assert diff == __(value=__(actual=1.0, expected=('close_to', 2.0, 0.5)))
839+
#assert diff['value']['expected'] == ('close_to', 2.0, 0.5)
830840

831841
# Test operators with real-world patterns
832842

@@ -1189,4 +1199,212 @@ def test_contains__with_kwargs__integration_test(self):
11891199
status=__BETWEEN__(200, 299),
11901200
request_id=__SKIP__,
11911201
metrics=__(score=__GREATER_THAN__(85))
1192-
)
1202+
)
1203+
1204+
def test_contains__with_missing_marker(self): # Test contains behavior with __MISSING__ marker
1205+
with __(a=1, b=2) as _:
1206+
assert _.contains(c=__MISSING__) is True # confirms that 'c' is missing
1207+
1208+
1209+
def test__empty_nested_objects(self): # Test comparison with empty nested __ objects
1210+
with __(data=__()) as obj1:
1211+
assert obj1 == __(data=__())
1212+
assert obj1.contains(__(data=__()))
1213+
1214+
diff = obj1.diff(__(data=__(x=1)))
1215+
assert diff == __(data=__(actual=__(), expected=__(x=1)))
1216+
1217+
def test__deeply_nested_mixed_operators_and_skip(self):
1218+
"""Test 4+ levels of nesting with operators and skip"""
1219+
complex = __(
1220+
level1=__(
1221+
level2=__(
1222+
level3=__(
1223+
level4=__(value=42, timestamp=999)
1224+
)
1225+
)
1226+
)
1227+
)
1228+
1229+
assert complex == __(
1230+
level1=__(
1231+
level2=__(
1232+
level3=__(
1233+
level4=__(
1234+
value=__GREATER_THAN__(40),
1235+
timestamp=__SKIP__
1236+
)
1237+
)
1238+
)
1239+
)
1240+
)
1241+
1242+
1243+
def test__diff_return_value_consistency(self): # Test diff returns __() for no differences
1244+
with __(a=1) as obj1:
1245+
with __(a=1) as obj2:
1246+
diff = obj1.diff(obj2)
1247+
assert diff == __() # there were no differences
1248+
1249+
def test__diff_ignores_skip_markers(self): # Test that __SKIP__ values don't appear in diff results
1250+
1251+
# Case 1: __SKIP__ in left side
1252+
with __(a=1, b=__SKIP__, c=3) as obj1:
1253+
with __(a=1, b=2, c=3) as obj2:
1254+
diff = obj1.diff(obj2)
1255+
assert diff == __() # No diff because b is skipped
1256+
1257+
# Case 2: __SKIP__ in right side (more common in tests)
1258+
with __(a=1, b=2, c=3) as obj1:
1259+
with __(a=1, b=__SKIP__, c=3) as obj2:
1260+
diff = obj1.diff(obj2)
1261+
assert diff == __() # No diff because b is skipped
1262+
1263+
# Case 3: __SKIP__ on both sides
1264+
with __(a=1, b=__SKIP__) as obj1:
1265+
with __(a=1, b=__SKIP__) as obj2:
1266+
diff = obj1.diff(obj2)
1267+
assert diff == __()
1268+
1269+
# Case 4: Other fields still show differences
1270+
with __(a=1, b=__SKIP__, c=3) as obj1:
1271+
with __(a=2, b=2, c=3) as obj2:
1272+
diff = obj1.diff(obj2)
1273+
assert diff == __(a=__(actual=1, expected=2)) # Only 'a' differs, 'b' ignored
1274+
1275+
1276+
def test_contains__with_missing_marker__basic(self):
1277+
"""Test __MISSING__ confirms field absence"""
1278+
with __(a=1, b=2) as _:
1279+
assert _.contains(c=__MISSING__) # Field 'c' doesn't exist - passes
1280+
assert _.contains(d=__MISSING__) # Field 'd' doesn't exist - passes
1281+
assert not _.contains(a=__MISSING__) # Field 'a' EXISTS - fails
1282+
assert not _.contains(b=__MISSING__) # Field 'b' EXISTS - fails
1283+
1284+
def test_contains__with_missing_marker__mixed_with_existing(self):
1285+
"""Test __MISSING__ combined with regular field checks"""
1286+
with __(a=1, b=2, c=3) as _:
1287+
# Check some fields exist AND some don't exist
1288+
assert _.contains(a=1, d=__MISSING__) # 'a' exists with value 1, 'd' missing
1289+
assert _.contains(b=2, c=3, x=__MISSING__) # Multiple exist, one missing
1290+
assert not _.contains(a=1, b=__MISSING__) # 'b' exists, expected missing - fails
1291+
1292+
def test_contains__with_missing_marker__nested_objects(self):
1293+
"""Test __MISSING__ with nested structures"""
1294+
with __(user=__(id='u1', name='Alice'), config=__(timeout=30)) as _:
1295+
# Check top-level field is missing
1296+
assert _.contains(settings=__MISSING__) # 'settings' doesn't exist
1297+
1298+
# Check nested field exists, but another top-level is missing
1299+
assert _.contains(user=__(id='u1'), metadata=__MISSING__)
1300+
1301+
# Field exists when expected missing - fails
1302+
assert not _.contains(user=__MISSING__) # 'user' EXISTS
1303+
1304+
def test_contains__with_missing_marker__with_operators(self):
1305+
"""Test __MISSING__ combined with comparison operators"""
1306+
with __(score=85, duration=0.234) as _:
1307+
assert _.contains(
1308+
score=__GREATER_THAN__(80), # Field exists and passes operator
1309+
latency=__MISSING__ # Field doesn't exist
1310+
)
1311+
1312+
assert _.contains(
1313+
duration=__LESS_THAN__(0.5),
1314+
timestamp=__MISSING__,
1315+
request_id=__MISSING__
1316+
)
1317+
1318+
def test_contains__with_missing_marker__all_missing(self):
1319+
"""Test checking multiple missing fields"""
1320+
with __(a=1) as _:
1321+
assert _.contains(b=__MISSING__, c=__MISSING__, d=__MISSING__) # All missing
1322+
assert not _.contains(a=__MISSING__, b=__MISSING__) # 'a' exists
1323+
1324+
def test_contains__with_missing_marker__empty_object(self):
1325+
"""Test __MISSING__ on completely empty object"""
1326+
with __() as _:
1327+
assert _.contains(a=__MISSING__) # Everything is missing in empty object
1328+
assert _.contains(x=__MISSING__, y=__MISSING__)
1329+
1330+
def test_contains__with_missing_marker__real_world_validation(self):
1331+
"""Real-world pattern: validate deprecated fields removed"""
1332+
api_response_v2 = __(
1333+
status=200,
1334+
data=__(items=[1, 2, 3], total=3),
1335+
metadata=__(version='2.0')
1336+
)
1337+
1338+
# Validate new fields exist AND old deprecated fields are gone
1339+
assert api_response_v2.contains(
1340+
status=200, # New field exists
1341+
data=__(total=3), # Nested field exists
1342+
deprecated_user_id=__MISSING__, # Old field removed
1343+
legacy_timestamp=__MISSING__ # Old field removed
1344+
)
1345+
1346+
def test_contains__with_missing_marker__with_skip(self):
1347+
"""Test __MISSING__ combined with __SKIP__"""
1348+
with __(a=1, b=2, c=3) as _:
1349+
assert _.contains(
1350+
a=__SKIP__, # Don't care about 'a' value
1351+
d=__MISSING__, # 'd' must not exist
1352+
b=2 # 'b' must equal 2
1353+
)
1354+
1355+
def test_contains__with_missing_marker__validates_field_removal(self):
1356+
"""Test pattern for validating fields were removed in transformation"""
1357+
original = __(id='123', name='Test', password='secret', ssn='123-45-6789')
1358+
1359+
# After sanitization
1360+
sanitized = __(id='123', name='Test')
1361+
1362+
# Validate sensitive fields removed
1363+
assert sanitized.contains(
1364+
id='123', # Keep public fields
1365+
name='Test',
1366+
password=__MISSING__, # Sensitive removed
1367+
ssn=__MISSING__ # Sensitive removed
1368+
)
1369+
1370+
# Original should NOT contain missing (fields exist)
1371+
assert not original.contains(password=__MISSING__)
1372+
assert not original.contains(ssn=__MISSING__)
1373+
1374+
def test_contains__with_missing_marker__object_syntax(self):
1375+
"""Test __MISSING__ with __ object syntax (not kwargs)"""
1376+
with __(a=1, b=2) as _:
1377+
assert _.contains(__(c=__MISSING__)) # Object syntax
1378+
assert _.contains(__(a=1, d=__MISSING__)) # Mixed
1379+
assert not _.contains(__(a=__MISSING__)) # Field exists
1380+
1381+
1382+
1383+
1384+
##############
1385+
# KNOWN bugs
1386+
1387+
# this documents this scenario
1388+
def test__bug__circular_reference_handling__recursion_is_not_handled(self): # Test behavior with circular references
1389+
obj1 = __(a=1, b=2)
1390+
obj1.self_ref = obj1 # Circular reference
1391+
1392+
obj2 = __(a=1, b=2)
1393+
obj2.self_ref = obj2
1394+
error_message = "maximum recursion depth exceeded"
1395+
with pytest.raises(RecursionError, match=error_message): # Should this handle gracefully or infinite loop?
1396+
assert obj1 == obj2
1397+
1398+
def test__bug__diff_with_nested_operators(self): # Test diff with operators in nested structures
1399+
actual = __(user=__(score=85, name='Alice'),
1400+
stats=__(wins=10) )
1401+
1402+
expected = __(user=__(score=__GREATER_THAN__(90), name='Alice'),
1403+
stats=__(wins=__BETWEEN__(5, 15)))
1404+
1405+
diff = actual.diff(expected)
1406+
1407+
assert diff == __(stats = __(actual =__(wins=10),
1408+
expected =__(wins=('between', 5, 15))), # BUG
1409+
user = __(actual =__(score=85, name='Alice'),
1410+
expected = __(score=('gt', 90), name='Alice'))) # Correct

tests/unit/testing/test__using_Type_Safe.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ def test__obj_diff_for_debugging(self):
144144
user2.age = 26 # Different age
145145

146146
diff = user1.obj().diff(user2.obj())
147-
assert diff == {'email': {'actual': '[email protected]', 'expected': '[email protected]'},
148-
'age' : {'actual': 25, 'expected': 26}}
147+
assert diff == __(email = __(actual='[email protected]', expected='[email protected]'),
148+
age = __(actual=25, expected=26))
149149

150150
def test__obj_merge_for_test_variations(self): # Test merge with Type_Safe .obj()
151151
with self.Schema__User() as base_user:

0 commit comments

Comments
 (0)