diff --git a/src/dabmodel/model.py b/src/dabmodel/model.py index 57adf45..f02ad3d 100644 --- a/src/dabmodel/model.py +++ b/src/dabmodel/model.py @@ -31,7 +31,7 @@ import inspect, ast, textwrap from frozendict import deepfreeze from typeguard import check_type, TypeCheckError, CollectionCheckStrategy -ALLOWED_ANNOTATIONS: dict[str, type] = { +ALLOWED_ANNOTATIONS: dict[str, Any] = { "Union": Union, "Optional": Optional, "List": List, @@ -55,7 +55,14 @@ ALLOWED_ANNOTATIONS: dict[str, type] = { "tuple": tuple, } -ALLOWED_MODEL_FIELDS_TYPES: tuple[type] = (str, int, float, complex, bool, bytes) +ALLOWED_MODEL_FIELDS_TYPES: tuple[type[Any], ...] = ( + str, + int, + float, + complex, + bool, + bytes, +) class DABModelException(Exception): diff --git a/test/test_model.py b/test/test_model.py index 0956450..e636f0e 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -1876,14 +1876,6 @@ class MainTests(unittest.TestCase): self.immutable_vars__test_field(app2, "testVar5", ("a", "b"), ("h", "c")) self.immutable_vars__test_field(app2, "testVar6", ("a", "b"), ("h", "c")) - def test_field_is_readonly(self): - class App(dm.BaseAppliance): - x: int = 1 - - app = App() - with self.assertRaises(dm.ReadOnlyField): - app.x = 2 - def test_new_field_forbidden(self): class App(dm.BaseAppliance): x: int = 1 @@ -1974,22 +1966,6 @@ class MainTests(unittest.TestCase): with self.assertRaises(dm.ReadOnlyField): app.x = 99 - def test_new_public_field_forbidden(self): - class App(dm.BaseAppliance): - x: int = 1 - - app = App() - with self.assertRaises(dm.NewFieldForbidden): - app.newfield = "oops" - - def test_private_field_allowed_assignment(self): - class App(dm.BaseAppliance): - x: int = 1 - - app = App() - app._debug = "ok" - self.assertEqual(app._debug, "ok") - def test_feature_override_does_not_leak_between_instances(self): class App(dm.BaseAppliance): class F1(dm.BaseFeature): @@ -2071,31 +2047,6 @@ class MainTests(unittest.TestCase): class C(A, B): pass - def test_wrong_feature_override(self): - class App(dm.BaseAppliance): - class F1(dm.BaseFeature): - val: int = 1 - - with self.assertRaises(dm.InvalidFieldValue): - App(F1={"doesnotexist": 123}) - - def test_nested_list_frozen(self): - class A(dm.BaseAppliance): - x: list[list[int]] = [[1, 2]] - - a = A() - with self.assertRaises(AttributeError): - a.x[0].append(3) # inner list should also be frozen - - def test_feature_override_no_leakage(self): - class App(dm.BaseAppliance): - class F1(dm.BaseFeature): - val: int = 1 - - a1 = App(F1={"val": 99}) - a2 = App() - self.assertEqual(a2.F1.val, 1) # no leakage - def test_initializer_can_use_math(self): class App(dm.BaseAppliance): @@ -2110,6 +2061,426 @@ class MainTests(unittest.TestCase): # math.ceil(1.2)=2, math.floor(2.8)=2 → 4 self.assertEqual(app.val, 4) + def test_deepfreeze_nested_mixed_tuple_list(self): + class App(dm.BaseAppliance): + data: tuple[list[int], tuple[int, list[int]]] = ([1, 2], (3, [4, 5])) + + app = App() + + # Top-level: must be tuple + self.assertIsInstance(app.data, tuple) + + # First element of tuple: should have been frozen to tuple, not list + self.assertIsInstance(app.data[0], tuple) + + # Nested second element: itself a tuple + self.assertIsInstance(app.data[1], tuple) + + # Deepest element: inner list should also be frozen to tuple + self.assertIsInstance(app.data[1][1], tuple) + + # Check immutability + with self.assertRaises(TypeError): + app.data[0] += (99,) # tuples are immutable + + with self.assertRaises(TypeError): + app.data[1][1] += (42,) # inner tuple also immutable + + def test_inacurate_type(self): + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance1(dm.BaseAppliance): + SomeVar: list = [] + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance2(dm.BaseAppliance): + SomeVar: list[Any] = [] + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance3(dm.BaseAppliance): + SomeVar: list[object] = [] + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance4(dm.BaseAppliance): + SomeVar: dict = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance5(dm.BaseAppliance): + SomeVar: dict[str, Any] = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance6(dm.BaseAppliance): + SomeVar: dict[Any, Any] = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance7(dm.BaseAppliance): + SomeVar: dict[Any, str] = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance8(dm.BaseAppliance): + SomeVar: dict[str, object] = {} + + def test_deepfreeze_nested_dict_list_set(self): + class App(dm.BaseAppliance): + data: dict[str, list[int] | set[str] | dict[str, list[int] | set[str]]] = { + "numbers": [1, 2, 3], + "letters": {"a", "b", "c"}, + "mixed": {"x": [4, 5], "y": {"z"}}, + } + + app = App() + + # Top-level: should be frozendict + self.assertEqual(type(app.data).__name__, "frozendict") + + # Lists must be frozen to tuple + self.assertIsInstance(app.data["numbers"], tuple) + self.assertIsInstance(app.data["mixed"]["x"], tuple) + + # Sets must be frozen to frozenset + self.assertIsInstance(app.data["letters"], frozenset) + self.assertIsInstance(app.data["mixed"]["y"], frozenset) + + # Check immutability + with self.assertRaises(TypeError): + app.data["numbers"] += (99,) + + with self.assertRaises(AttributeError): + app.data["letters"].add("d") + + with self.assertRaises(TypeError): + app.data["mixed"]["x"] += (6,) + + def test_unknown_parameters_raise(self): + class App(dm.BaseAppliance): + a: int = 1 + + with self.assertRaises(dm.InvalidFieldValue) as cm: + App(foo=123) + + self.assertIn("Unknown parameters:", str(cm.exception)) + self.assertIn("foo", str(cm.exception)) + + def test_variadic_tuple_annotation_ok_and_errors(self): + class A(dm.BaseAppliance): + xs: tuple[int, ...] = (1, 2, 3) + + self.assertEqual(A().xs, (1, 2, 3)) + + class B(dm.BaseAppliance): + xs: "Tuple[int, ...]" = (4, 5) + + self.assertEqual(B().xs, (4, 5)) + + # wrong inner type should fail + with self.assertRaises(dm.InvalidFieldValue): + + class _Bad(dm.BaseAppliance): + xs: tuple[int, ...] = (1, "x") + + def test_field_override_typechecked_and_instance_local(self): + class App(dm.BaseAppliance): + a: list[int] = [1] + + app1 = App(a=[9]) + app2 = App() + # deepfreeze: lists become tuples + self.assertEqual(app1.a, (9,)) + self.assertEqual(app2.a, (1,)) # no leakage + + with self.assertRaises(dm.InvalidFieldValue): + App(a=["oops"]) # wrong inner type + + def test_runtime_attach_bound_feature_success(self): + class App(dm.BaseAppliance): + class F1(dm.BaseFeature): + val: int = 1 + + class Extra(App.F1): # stays bound to App + val = 7 + + app = App(Extra=Extra) + self.assertTrue(hasattr(app, "Extra")) + self.assertIsInstance(app.Extra, Extra) + self.assertEqual(app.Extra.val, 7) + + def test_cant_override_inherited_feature_annotation(self): + class App(dm.BaseAppliance): + class F1(dm.BaseFeature): + val: int = 1 + + with self.assertRaises(dm.ReadOnlyFieldAnnotation): + + class Extra(App.F1): + val: str = "test" + + def test_feature_fields_are_frozen_after_override(self): + class App(dm.BaseAppliance): + class F(dm.BaseFeature): + nums: list[int] = [1, 2] + tag: str = "x" + + # dict override + app1 = App(F={"nums": [9], "tag": "y"}) + self.assertEqual(app1.F.nums, (9,)) + self.assertEqual(app1.F.tag, "y") + with self.assertRaises(AttributeError): + app1.F.nums.append(3) # tuple + + # subclass override + class F2(App.F): + nums = [4, 5] + + app2 = App(F=F2) + self.assertEqual(app2.F.nums, (4, 5)) + with self.assertRaises(dm.ReadOnlyField): + app2.F.nums += (6,) # still immutable + + def test_feature_dict_partial_override_keeps_other_defaults(self): + class App(dm.BaseAppliance): + class F(dm.BaseFeature): + a: int = 1 + b: str = "k" + + app = App(F={"b": "z"}) + self.assertEqual(app.F.a, 1) # default remains + self.assertEqual(app.F.b, "z") # overridden + + def test_root_field_override_nonexisting_rejected(self): + class App(dm.BaseAppliance): + x: int = 1 + + with self.assertRaises(dm.InvalidFieldValue): + App(y=2) # not in schema -> unknown parameter + + def test_feature_override_linear_chain(self): + # Base appliance defines Feat1 + class A(dm.BaseAppliance): + class Feat1(dm.BaseFeature): + x: int = 1 + + # ✅ Appliance B overrides Feat1 by subclassing A.Feat1 + class B(A): + class Feat1(A.Feat1): + y: int = 2 + + self.assertTrue(issubclass(B.Feat1, A.Feat1)) + + # ✅ Appliance C overrides Feat1 again by subclassing B.Feat1 (not A.Feat1) + class C(B): + class Feat1(B.Feat1): + z: int = 3 + + self.assertTrue(issubclass(C.Feat1, B.Feat1)) + self.assertTrue(issubclass(C.Feat1, A.Feat1)) + + # ❌ Bad: D tries to override with a *fresh* BaseFeature, not subclass of B.Feat1 + with self.assertRaises(dm.InvalidFeatureInheritance): + + class D(B): + class Feat1(dm.BaseFeature): + fail: str = "oops" + + # ❌ Bad: E tries to override with ancestor (A.Feat1) instead of B.Feat1 + with self.assertRaises(dm.InvalidFeatureInheritance): + + class E(B): + class Feat1(A.Feat1): + fail: str = "oops" + + # ✅ New feature name in child is always fine + class F(B): + class Feat2(dm.BaseFeature): + other: str = "ok" + + self.assertTrue(hasattr(F, "Feat2")) + + def test_feature_override_chain_runtime_replacement(self): + # Build a linear chain: A -> B -> C for feature 'Feat1' + class A(dm.BaseAppliance): + class Feat1(dm.BaseFeature): + x: int = 1 + + class B(A): + class Feat1(A.Feat1): + y: int = 2 + + class C(B): + class Feat1(B.Feat1): + z: int = 3 + + # ✅ OK: at instantiation of C, replacing Feat1 with a subclass of the LATEST (C.Feat1) + class CFeat1Plus(C.Feat1): + w: int = 4 + + c_ok = C(Feat1=CFeat1Plus) + self.assertIsInstance(c_ok.Feat1, CFeat1Plus) + self.assertEqual( + (c_ok.Feat1.x, c_ok.Feat1.y, c_ok.Feat1.z, c_ok.Feat1.w), (1, 2, 3, 4) + ) + + # ❌ Not OK: replacing with a subclass of the ancestor (A.Feat1) — must target latest (C.Feat1) + class AFeat1Alt(A.Feat1): + pass + + with self.assertRaises(dm.InvalidFieldValue): + C(Feat1=AFeat1Alt) + + # ❌ Not OK: replacing with a subclass of the mid ancestor (B.Feat1) — still must target latest (C.Feat1) + class BFeat1Alt(B.Feat1): + pass + + with self.assertRaises(dm.InvalidFieldValue): + C(Feat1=BFeat1Alt) + + def test_feature_inheritance_tree_and_no_leakage(self): + class A(dm.BaseAppliance): + class F1(dm.BaseFeature): + a: int = 1 + + class F2(dm.BaseFeature): + b: int = 2 + + # ✅ Child inherits both features automatically + class B(A): + c: str = "extra" + + b1 = B() + self.assertIsInstance(b1.F1, A.F1) + self.assertIsInstance(b1.F2, A.F2) + self.assertEqual((b1.F1.a, b1.F2.b, b1.c), (1, 2, "extra")) + + # ✅ Override only F2, F1 should still come from A + class C(B): + class F2(B.F2): + bb: int = 22 + + c1 = C() + self.assertIsInstance(c1.F1, A.F1) # unchanged + self.assertIsInstance(c1.F2, C.F2) # overridden + self.assertEqual((c1.F1.a, c1.F2.b, c1.F2.bb), (1, 2, 22)) + + # ✅ No leakage: instances of B are not affected by C's override + b2 = B() + self.assertIsInstance(b2.F2, A.F2) + self.assertFalse(hasattr(b2.F2, "bb")) + + # ✅ Adding a new feature in D is independent of previous appliances + class D(C): + class F3(dm.BaseFeature): + d: int = 3 + + d1 = D() + self.assertIsInstance(d1.F1, A.F1) + self.assertIsInstance(d1.F2, C.F2) + self.assertIsInstance(d1.F3, D.F3) + + # ✅ No leakage: instances of A and B should not see F3 + a1 = A() + self.assertFalse(hasattr(a1, "F3")) + b3 = B() + self.assertFalse(hasattr(b3, "F3")) + + def test_appliance_inheritance_tree_feature_isolation(self): + class A(dm.BaseAppliance): + class F1(dm.BaseFeature): + a: int = 1 + + # Branch 1 overrides F1 + class B(A): + class F1(A.F1): + b: int = 2 + + # Branch 2 also overrides F1 differently + class C(A): + class F1(A.F1): + c: int = 3 + + # ✅ Instances of B use B.F1 + b = B() + self.assertIsInstance(b.F1, B.F1) + self.assertEqual((b.F1.a, b.F1.b), (1, 2)) + self.assertFalse(hasattr(b.F1, "c")) + + # ✅ Instances of C use C.F1 + c = C() + self.assertIsInstance(c.F1, C.F1) + self.assertEqual((c.F1.a, c.F1.c), (1, 3)) + self.assertFalse(hasattr(c.F1, "b")) + + # ✅ Base appliance A still uses its original feature + a = A() + self.assertIsInstance(a.F1, A.F1) + self.assertEqual(a.F1.a, 1) + self.assertFalse(hasattr(a.F1, "b")) + self.assertFalse(hasattr(a.F1, "c")) + + # ✅ No leakage: B's override doesn't affect C and vice versa + b2 = B() + c2 = C() + self.assertTrue(hasattr(b2.F1, "b")) + self.assertFalse(hasattr(b2.F1, "c")) + self.assertTrue(hasattr(c2.F1, "c")) + self.assertFalse(hasattr(c2.F1, "b")) + + def test_appliance_inheritance_tree_runtime_attach_isolation(self): + class A(dm.BaseAppliance): + class F1(dm.BaseFeature): + a: int = 1 + + class B(A): + class F1(A.F1): + b: int = 2 + + class C(A): + class F1(A.F1): + c: int = 3 + + # Define new runtime-attachable features + class FextraB(B.F1): + xb: int = 99 + + class FextraC(C.F1): + xc: int = -99 + + # ✅ Attach to B at instantiation + b = B(F1=FextraB) + self.assertIsInstance(b.F1, FextraB) + self.assertEqual((b.F1.a, b.F1.b, b.F1.xb), (1, 2, 99)) + self.assertFalse(hasattr(b.F1, "c")) + self.assertFalse(hasattr(b.F1, "xc")) + + # ✅ Attach to C at instantiation + c = C(F1=FextraC) + self.assertIsInstance(c.F1, FextraC) + self.assertEqual((c.F1.a, c.F1.c, c.F1.xc), (1, 3, -99)) + self.assertFalse(hasattr(c.F1, "b")) + self.assertFalse(hasattr(c.F1, "xb")) + + # ✅ Base appliance still untouched + a = A() + self.assertIsInstance(a.F1, A.F1) + self.assertEqual(a.F1.a, 1) + self.assertFalse(hasattr(a.F1, "b")) + self.assertFalse(hasattr(a.F1, "c")) + self.assertFalse(hasattr(a.F1, "xb")) + self.assertFalse(hasattr(a.F1, "xc")) + + # ✅ Repeated instantiations stay isolated + b2 = B() + c2 = C() + self.assertIsInstance(b2.F1, B.F1) + self.assertIsInstance(c2.F1, C.F1) + self.assertFalse(hasattr(b2.F1, "xb")) + self.assertFalse(hasattr(c2.F1, "xc")) + # ---------- main ----------