diff --git a/src/dabmodel/LAMFields/Constraint.py b/src/dabmodel/LAMFields/Constraint.py index 1c1d64c..e9d1e3a 100644 --- a/src/dabmodel/LAMFields/Constraint.py +++ b/src/dabmodel/LAMFields/Constraint.py @@ -3,8 +3,8 @@ from typing import Generic, TypeVar T_Field = TypeVar("T_Field") -class BaseConstraint(Generic[T_Field]): - """BaseConstraint class +class Constraint(Generic[T_Field]): + """Constraint class Base class for Field's constraints """ diff --git a/src/dabmodel/LAMFields/FrozenLAMField.py b/src/dabmodel/LAMFields/FrozenLAMField.py index 5b45d55..8df202d 100644 --- a/src/dabmodel/LAMFields/FrozenLAMField.py +++ b/src/dabmodel/LAMFields/FrozenLAMField.py @@ -1,7 +1,7 @@ from typing import Generic, TypeVar, Any from .LAMField import LAMField -from .Constraint import BaseConstraint +from .Constraint import Constraint from ..tools import LAMdeepfreeze T_Field = TypeVar("T_Field") @@ -12,7 +12,7 @@ class FrozenLAMField(Generic[T_Field]): a read-only proxy of a Field """ - def __init__(self, inner_field: LAMField): + def __init__(self, inner_field: LAMField[T_Field]): self._inner_field = inner_field @property @@ -21,7 +21,7 @@ class FrozenLAMField(Generic[T_Field]): return LAMdeepfreeze(self._inner_field.doc) @property - def constraints(self) -> tuple[BaseConstraint]: + def constraints(self) -> tuple[Constraint]: """Returns Field's constraint (frozen)""" return LAMdeepfreeze(self._inner_field.constraints) diff --git a/src/dabmodel/LAMFields/LAMCompatible.py b/src/dabmodel/LAMFields/LAMCompatible.py new file mode 100644 index 0000000..e3af4bf --- /dev/null +++ b/src/dabmodel/LAMFields/LAMCompatible.py @@ -0,0 +1,38 @@ +from typing import Generic, TypeVar, Self, Hashable, Any +from abc import ABC, abstractmethod +from ..tools import JSONType + +TV_LAMCompatbile = TypeVar("TV_LABCompatbile", bound="LABCompatible") + + +class LAMCompatible(Generic[TV_LAMCompatbile], ABC): + """Any type that can safely live inside a LABField.""" + + @classmethod + def lam_validate_annotation(cls, annotation: Any) -> None: + """ + Validate the type annotation (e.g., SpecialList[int]). + Raise if it's not compatible. + """ + return # default: do nothing (simple types don’t need it) + + @abstractmethod + def lam_validate(self) -> None: + """Raise if the value is invalid for this type.""" + ... + + @abstractmethod + def lam_freeze(self) -> Hashable: + """Return an immutable/hashable representation of this value.""" + ... + + @abstractmethod + def lam_to_plain(self) -> JSONType: + """Return a plain serializable form (str, dict, etc.).""" + ... + + @classmethod + @abstractmethod + def lam_from_plain(cls, plain: JSONType) -> Self: + """Return an Object from a plain serializable form (str, dict, etc.).""" + ... diff --git a/src/dabmodel/LAMFields/LAMField.py b/src/dabmodel/LAMFields/LAMField.py index 11575f7..2437520 100644 --- a/src/dabmodel/LAMFields/LAMField.py +++ b/src/dabmodel/LAMFields/LAMField.py @@ -1,23 +1,23 @@ from typing import Generic, TypeVar, Optional, Any from .LAMFieldInfo import LAMFieldInfo -from .Constraint import BaseConstraint +from .Constraint import Constraint from ..tools import LAMdeepfreeze -T_Field = TypeVar("T_Field") +TV_LABField = TypeVar("TV_LABField") -class LAMField(Generic[T_Field]): +class LAMField(Generic[TV_LABField]): """This class describe a Field in Schema""" - def __init__(self, name: str, v: Optional[T_Field], a: Any, i: LAMFieldInfo): + def __init__(self, name: str, v: Optional[TV_LABField], a: Any, i: LAMFieldInfo): self._name: str = name self._source: Optional[type] = None - self._default_value: Optional[T_Field] = v - self._value: Optional[T_Field] = v + self._default_value: Optional[TV_LABField] = v + self._value: Optional[TV_LABField] = v self._annotations: Any = a self._info: LAMFieldInfo = i - self._constraints: list[BaseConstraint[Any]] = i.constraints + self._constraints: list[Constraint[Any]] = i.constraints def add_source(self, s: type) -> None: """Adds source Appliance to the Field""" @@ -28,12 +28,12 @@ class LAMField(Generic[T_Field]): """Returns Field's documentation""" return self._info.doc - def add_constraint(self, c: BaseConstraint) -> None: + def add_constraint(self, c: Constraint) -> None: """Adds constraint to the Field""" self._constraints.append(c) @property - def constraints(self) -> list[BaseConstraint]: + def constraints(self) -> list[Constraint]: """Returns Field's constraint""" return self._info.constraints @@ -42,7 +42,7 @@ class LAMField(Generic[T_Field]): """Returns Field's default value (frozen)""" return LAMdeepfreeze(self._default_value) - def update_value(self, v: Optional[T_Field] = None) -> None: + def update_value(self, v: Optional[TV_LABField] = None) -> None: """Updates Field's value""" self._value = v @@ -52,7 +52,7 @@ class LAMField(Generic[T_Field]): return LAMdeepfreeze(self._value) @property - def raw_value(self) -> Optional[T_Field]: + def raw_value(self) -> Optional[TV_LABField]: """Returns Field's value""" return self._value diff --git a/src/dabmodel/LAMFields/LAMFieldInfo.py b/src/dabmodel/LAMFields/LAMFieldInfo.py index 3b0487c..880b6a7 100644 --- a/src/dabmodel/LAMFields/LAMFieldInfo.py +++ b/src/dabmodel/LAMFields/LAMFieldInfo.py @@ -1,15 +1,15 @@ from typing import Optional, Any -from .Constraint import BaseConstraint +from .Constraint import Constraint class LAMFieldInfo: """This Class allows to describe a Field in Appliance class""" def __init__( - self, *, doc: str = "", constraints: Optional[list[BaseConstraint]] = None + self, *, doc: str = "", constraints: Optional[list[Constraint]] = None ): self._doc: str = doc - self._constraints: list[BaseConstraint] + self._constraints: list[Constraint] if constraints is None: self._constraints = [] else: @@ -21,6 +21,6 @@ class LAMFieldInfo: return self._doc @property - def constraints(self) -> list[BaseConstraint[Any]]: + def constraints(self) -> list[Constraint[Any]]: """Returns Field's constraints""" return self._constraints diff --git a/src/dabmodel/__init__.py b/src/dabmodel/__init__.py index 2bb9fe5..3af5f89 100644 --- a/src/dabmodel/__init__.py +++ b/src/dabmodel/__init__.py @@ -16,8 +16,10 @@ from .__metadata__ import __version__, __Summuary__, __Name__ from .LAMFields.LAMField import LAMField from .LAMFields.LAMFieldInfo import LAMFieldInfo from .LAMFields.FrozenLAMField import FrozenLAMField +from .LAMFields.LAMCompatible import LAMCompatible from .appliance import Appliance from .feature import Feature +from .element import Element from .exception import ( @@ -35,6 +37,7 @@ from .exception import ( FunctionForbidden, InvalidFeatureInheritance, FeatureNotBound, + UnsupportedFieldType, ) __all__ = [name for name in globals() if not name.startswith("_")] diff --git a/src/dabmodel/appliance.py b/src/dabmodel/appliance.py index c65ce55..af2b76e 100644 --- a/src/dabmodel/appliance.py +++ b/src/dabmodel/appliance.py @@ -2,7 +2,7 @@ from .element import Element from .meta.appliance import _MetaAppliance -class Appliance(Element, metaclass=_MetaAppliance): +class Appliance(metaclass=_MetaAppliance): """BaseFeature class Base class for Appliance. An appliance is a server configuration / image that is built using appliance's code and Fields. diff --git a/src/dabmodel/element.py b/src/dabmodel/element.py index 3567248..bf30164 100644 --- a/src/dabmodel/element.py +++ b/src/dabmodel/element.py @@ -1,7 +1,7 @@ -from .meta.base import _MetaElement +from .meta.element import _MetaElement, IElement -class Element(metaclass=_MetaElement): +class Element(IElement, metaclass=_MetaElement): """Element class Base class to apply metaclass and set common Fields. """ diff --git a/src/dabmodel/exception.py b/src/dabmodel/exception.py index b4ebbf1..948c291 100644 --- a/src/dabmodel/exception.py +++ b/src/dabmodel/exception.py @@ -67,6 +67,12 @@ class IncompletelyAnnotatedField(InvalidFieldAnnotation): """ +class UnsupportedFieldType(InvalidFieldAnnotation): + """UnsupportedFieldType Exception class + The field type is unsupported + """ + + class ReadOnlyFieldAnnotation(DABModelException): """ReadOnlyFieldAnnotation Exception class Field annotation connot be modified diff --git a/src/dabmodel/feature.py b/src/dabmodel/feature.py index f5fe80b..634f9c7 100644 --- a/src/dabmodel/feature.py +++ b/src/dabmodel/feature.py @@ -2,7 +2,7 @@ from .element import Element from .meta.feature import _MetaFeature -class Feature(Element, metaclass=_MetaFeature): +class Feature(metaclass=_MetaFeature): """Feature class Base class for Appliance's Features. Features are optional traits of an appliance. diff --git a/src/dabmodel/meta/appliance.py b/src/dabmodel/meta/appliance.py index 66fc325..26a17dd 100644 --- a/src/dabmodel/meta/appliance.py +++ b/src/dabmodel/meta/appliance.py @@ -6,7 +6,7 @@ from typeguard import check_type, CollectionCheckStrategy, TypeCheckError from ..tools import LAMdeepfreeze from ..LAMFields.LAMField import LAMField from ..LAMFields.FrozenLAMField import FrozenLAMField -from .base import _MetaElement +from .element import _MetaElement from ..feature import Feature from ..exception import ( InvalidFieldValue, @@ -171,26 +171,6 @@ class _MetaAppliance(_MetaElement): MyApp(F1=MyF1) # inheritance / replacement MyApp(F1={"val": 42, ...}) # dict override of existing feature """ - # --- field overrides (unchanged) --- - for k, v in list(kwargs.items()): - if k in cls.__LAMSchema__: # regular field - field = cls.__LAMSchema__[k] - try: - check_type( - v, - field.annotations, - collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, - ) - except TypeCheckError as exp: - raise InvalidFieldValue( - f"Invalid value for field '{k}': expected {field.annotations}, got {v!r}" - ) from exp - - object.__setattr__(obj, k, LAMdeepfreeze(v)) - obj.__LAMSchema__[k] = FrozenLAMField( - LAMField(k, v, field.annotations, field._info) - ) - kwargs.pop(k) # --- feature overrides --- for k, v in list(kwargs.items()): @@ -230,6 +210,4 @@ class _MetaAppliance(_MetaElement): obj.__LAMSchema__["features"][k] = v kwargs.pop(k) - if kwargs: - unknown = ", ".join(sorted(kwargs.keys())) - raise InvalidFieldValue(f"Unknown parameters: {unknown}") + super().apply_overrides(obj, extensions, *args, **kwargs) diff --git a/src/dabmodel/meta/base.py b/src/dabmodel/meta/element.py similarity index 82% rename from src/dabmodel/meta/base.py rename to src/dabmodel/meta/element.py index 63bd554..0262767 100644 --- a/src/dabmodel/meta/base.py +++ b/src/dabmodel/meta/element.py @@ -23,10 +23,12 @@ import inspect, ast, textwrap from typeguard import check_type, TypeCheckError, CollectionCheckStrategy +from ..tools import LAMdeepfreeze from ..LAMFields.LAMField import LAMField from ..LAMFields.LAMFieldInfo import LAMFieldInfo from ..LAMFields.FrozenLAMField import FrozenLAMField + from ..exception import ( MultipleInheritanceForbidden, BrokenInheritance, @@ -41,6 +43,7 @@ from ..exception import ( FunctionForbidden, NonExistingField, InvalidInitializerType, + UnsupportedFieldType, ) ALLOWED_ANNOTATIONS: dict[str, Any] = { @@ -124,6 +127,10 @@ ALLOWED_HELPERS_DEFAULT: dict[str, object] = { } +class IElement: + pass + + def _check_initializer_safety(func) -> None: """ Preliminary structural check for __initializer__. @@ -145,38 +152,40 @@ def _check_initializer_safety(func) -> None: mod = ast.parse(src) # Find the FunctionDef node that corresponds to this initializer - init_node = None - for n in mod.body: - if ( - isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + init_node = next( + ( + n + for n in mod.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == func.__name__ - ): - init_node = n - break + ), + None, + ) if init_node is None: # Fallback: if not found, analyze nothing further to avoid false positives return - # Walk ONLY the body of the initializer (don't flag the def itself) - body_tree = ast.Module(body=init_node.body, type_ignores=[]) - - for node in ast.walk(body_tree): - # Forbid imports + for node in ast.walk(ast.Module(body=init_node.body, type_ignores=[])): if isinstance(node, (ast.Import, ast.ImportFrom)): raise ImportForbidden("imports disabled in __initializer") - - # Forbid nested defs (but allow lambdas) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - raise FunctionForbidden( - "Nested function definitions are forbidden in __initializer" - ) - - if isinstance(node, ast.Lambda): # Forbid lambda - raise FunctionForbidden("Lambdas are forbidden in __initializer") + raise FunctionForbidden("Nested defs are forbidden in __initializer") + # if isinstance(node, ast.Lambda): + # raise FunctionForbidden("Lambdas are forbidden in __initializer") # Optional: forbid closures (keeps determinism; allows lambdas that don't capture) if func.__code__.co_freevars: - raise FunctionForbidden("Closures are forbidden in __initializer__") + # Inspect captured free vars + closure_vars = inspect.getclosurevars(func) + captured = {**closure_vars.globals, **closure_vars.nonlocals} + for name, val in captured.items(): + if isinstance(val, type) and issubclass(val, IElement): + continue + if isinstance(val, (int, str, float, bool, type(None))): + continue + raise FunctionForbidden( + f"Closures are forbidden in __initializer__ (captured: {name}={val!r})" + ) def _blocked_import(*args, **kwargs): @@ -212,12 +221,15 @@ def _peel_annotated(t: Any) -> Any: def _check_annotation_definition( # pylint: disable=too-complex,too-many-return-statements _type, -) -> bool: +): + + print(f"_type={_type}") _type = _peel_annotated(_type) + _origin = get_origin(_type) or _type + _args = get_args(_type) + # handle Optional[] and Union[None,...] - if (get_origin(_type) is Union or get_origin(_type) is UnionType) and type( - None - ) in get_args(_type): + if (_origin is Union or _origin is UnionType) and type(None) in _args: return all( _check_annotation_definition(_) for _ in get_args(_type) @@ -225,43 +237,46 @@ def _check_annotation_definition( # pylint: disable=too-complex,too-many-return ) # handle other Union[...] - if get_origin(_type) is Union or get_origin(_type) is UnionType: - return all(_check_annotation_definition(_) for _ in get_args(_type)) + if _origin is Union or _origin is UnionType: + return all(_check_annotation_definition(_) for _ in _args) # handle Dict[...] - if get_origin(_type) is dict: - inner = get_args(_type) - if len(inner) != 2: + if _origin is dict: + print("a") + if len(_args) != 2: raise IncompletelyAnnotatedField( f"Dict Annotation requires 2 inner definitions: {_type}" ) - return _peel_annotated( - inner[0] - ) in ALLOWED_MODEL_FIELDS_TYPES and _check_annotation_definition(inner[1]) + if not _peel_annotated(_args[0]) in ALLOWED_MODEL_FIELDS_TYPES: + raise IncompletelyAnnotatedField( + f"Dict Key must be simple builtin: {_type}" + ) + return _check_annotation_definition(_args[1]) # handle Tuple[] - if get_origin(_type) in [tuple]: - inner_types = get_args(_type) - if len(inner_types) == 0: + if _origin is tuple: + if len(_args) == 0: raise IncompletelyAnnotatedField( f"Annotation requires inner definition: {_type}" ) - if len(inner_types) == 2 and inner_types[1] is Ellipsis: - return _check_annotation_definition(inner_types[0]) - return all(_check_annotation_definition(_) for _ in inner_types) + if len(_args) == 2 and _args[1] is Ellipsis: + return _check_annotation_definition(_args[0]) + return all(_check_annotation_definition(_) for _ in _args) # handle Set[],Tuple[],FrozenSet[],List[] - if get_origin(_type) in [set, frozenset, tuple, list]: - inner_types = get_args(_type) - if len(inner_types) == 0: + if _origin in [set, frozenset, tuple, list]: + if len(_args) == 0: raise IncompletelyAnnotatedField( f"Annotation requires inner definition: {_type}" ) - return all(_check_annotation_definition(_) for _ in inner_types) + return all(_check_annotation_definition(_) for _ in _args) + + if isinstance(_origin, type) and issubclass(_origin, IElement): + return if _type in ALLOWED_MODEL_FIELDS_TYPES: - return True - return False + return + raise UnsupportedFieldType(_type) class ModelSpecView: @@ -362,16 +377,20 @@ class _MetaElement(type): raise MultipleInheritanceForbidden( "Multiple inheritance is not supported by dabmodel" ) - if len(bases) == 0: # base class (BaseElement) - namespace["__DABSchema__"] = {} + if len(bases) == 0: # base class (Appliance,Feature) + namespace["__LAMSchema__"] = {} + elif ( + len(bases) == 1 and bases[0] is IElement + ): # special case for Element (hidden layer IElement) + namespace["__LAMSchema__"] = {} else: # standard inheritance # check class tree origin - if "__DABSchema__" not in dir(bases[0]): + if "__LAMSchema__" not in dir(bases[0]): raise BrokenInheritance( - "__DABSchema__ not found in base class, broken inheritance chain." + "__LAMSchema__ not found in base class, broken inheritance chain." ) # copy inherited schema - namespace["__DABSchema__"] = copy(bases[0].__LAMSchema__) + namespace["__LAMSchema__"] = copy(bases[0].__LAMSchema__) # force field without default value to be instantiated (with None) if "__annotations__" in namespace: @@ -411,7 +430,9 @@ class _MetaElement(type): name.startswith("_") and _fname == "__initializer" ): if not isinstance(_fvalue, classmethod): - raise InvalidInitializerType() + raise InvalidInitializerType( + "__initializer should be a classmethod" + ) mcs.initializer = _fvalue.__func__ if name.startswith("_"): initializer_name = "__initializer" @@ -424,7 +445,7 @@ class _MetaElement(type): else: print(f"Parsing Field: {_fname} / {_fvalue}") if ( - len(bases) == 1 and _fname in namespace["__DABSchema__"].keys() + len(bases) == 1 and _fname in namespace["__LAMSchema__"].keys() ): # Modified fields mcs.process_modified_field( name, bases, namespace, _fname, _fvalue, extensions @@ -464,7 +485,7 @@ class _MetaElement(type): try: check_type( _fvalue, - namespace["__DABSchema__"][_fname].annotations, + namespace["__LAMSchema__"][_fname].annotations, collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, ) except TypeCheckError as exp: @@ -490,7 +511,7 @@ class _MetaElement(type): payloads (allowing only LAMFieldInfo), checks the default value type, and stages the field as a `LAMField` in `mcs.new_fields`. """ - # print(f"New field: {_fname}") + print(f"New field: {_fname}") # check if field is annotated if ( @@ -507,9 +528,13 @@ class _MetaElement(type): namespace["__annotations__"][_fname] ) - if not _check_annotation_definition(namespace["__annotations__"][_fname]): + try: + _check_annotation_definition(namespace["__annotations__"][_fname]) + except InvalidFieldAnnotation: + raise + except Exception as ex: raise InvalidFieldAnnotation( - f"Field <{_fname}> has not an allowed or valid annotation." + f"Field <{_fname}> has not an allowed or valid annotation.", ex ) _finfo: LAMFieldInfo = LAMFieldInfo() @@ -544,6 +569,7 @@ class _MetaElement(type): raise InvalidFieldValue( f"Value of Field <{_fname}> is not of expected type {namespace['__annotations__'][_fname]}." ) from exp + print(f"!!VAL: {_fvalue}") mcs.new_fields[_fname] = LAMField( _fname, _fvalue, namespace["__annotations__"][_fname], _finfo ) @@ -580,14 +606,14 @@ class _MetaElement(type): "__builtins__": {"__import__": _blocked_import}, **ALLOWED_HELPERS_DEFAULT, } - if mcs.initializer.__code__.co_freevars: - raise FunctionForbidden("__initializer must not use closures") + # if mcs.initializer.__code__.co_freevars: + # raise FunctionForbidden("__initializer must not use closures") safe_initializer = FunctionType( mcs.initializer.__code__, safe_globals, name=mcs.initializer.__name__, argdefs=mcs.initializer.__defaults__, - closure=None, + closure=mcs.initializer.__closure__, ) safe_initializer(fakecls) # pylint: disable=not-callable for _fname, _fvalue in fakecls.export().items(): @@ -601,6 +627,7 @@ class _MetaElement(type): raise InvalidFieldValue( f"Value of Field <{_fname}> is not of expected type {namespace['__annotations__'][_fname]}." ) from exp + cls.__LAMSchema__[_fname] = deepcopy(cls.__LAMSchema__[_fname]) cls.__LAMSchema__[_fname].update_value(_fvalue) def __new__( @@ -632,7 +659,7 @@ class _MetaElement(type): extensions: dict[str, Any], ): """ - Commit staged fields into the class schema (`__DABSchema__`). + Commit staged fields into the class schema (`__LAMSchema__`). - For modified fields: copy the parent's LAMField, update its value. - For new fields: set the freshly built LAMField and record its source. @@ -682,7 +709,7 @@ class _MetaElement(type): """ Freeze the instance's schema by wrapping DABFields into FrozenLAMField. - Creates a per-instance `__DABSchema__` dict where each field is read-only. + Creates a per-instance `__LAMSchema__` dict where each field is read-only. """ inst_schema = copy(obj.__LAMSchema__) for _fname, _fvalue in cls.__LAMSchema__.items(): @@ -692,7 +719,7 @@ class _MetaElement(type): if "features" in inst_schema: inst_schema["features"] = dict(inst_schema["features"]) - object.__setattr__(obj, "__DABSchema__", inst_schema) + object.__setattr__(obj, "__LAMSchema__", inst_schema) def apply_overrides(cls, obj, extensions, *args, **kwargs): """ @@ -702,12 +729,33 @@ class _MetaElement(type): Subclasses of _MetaElement can override this to support things like: - Field overrides: MyApp(field=value) - - Feature overrides: MyApp(FeatureName=CustomFeature) - - Feature attachments: MyApp(NewFeature=FeatureClass) - - By default this does nothing. """ + # --- field overrides (unchanged) --- + for k, v in list(kwargs.items()): + if k in cls.__LAMSchema__: # regular field + field = cls.__LAMSchema__[k] + try: + check_type( + v, + field.annotations, + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ) + except TypeCheckError as exp: + raise InvalidFieldValue( + f"Invalid value for field '{k}': expected {field.annotations}, got {v!r}" + ) from exp + + object.__setattr__(obj, k, LAMdeepfreeze(v)) + obj.__LAMSchema__[k] = FrozenLAMField( + LAMField(k, v, field.annotations, field._info) + ) + kwargs.pop(k) + + if kwargs: + unknown = ", ".join(sorted(kwargs.keys())) + raise InvalidFieldValue(f"Unknown parameters: {unknown}") + def finalize_instance(cls: Type, obj: Any, extensions: dict[str, Any]): """ Finalization hook invoked at the end of instance construction. @@ -738,7 +786,7 @@ class _MetaElement(type): if key in _self.__LAMSchema__.keys(): if key in _self.__dict__: raise ReadOnlyField(f"{key} is read-only") - # elif key in _self.__DABSchema__["features"].keys(): + # elif key in _self.__LAMSchema__["features"].keys(): # if key in _self.__dict__: # raise ReadOnlyField(f"{key} is read-only") else: diff --git a/src/dabmodel/meta/feature.py b/src/dabmodel/meta/feature.py index 1c25012..c4c6a93 100644 --- a/src/dabmodel/meta/feature.py +++ b/src/dabmodel/meta/feature.py @@ -1,5 +1,5 @@ from typing import Type, Any -from .base import _MetaElement +from .element import _MetaElement from ..exception import FeatureNotBound diff --git a/src/dabmodel/tools.py b/src/dabmodel/tools.py index 85697e2..03e54ba 100644 --- a/src/dabmodel/tools.py +++ b/src/dabmodel/tools.py @@ -1,11 +1,15 @@ """library's internal tools""" +from typing import Union, List, Any, Dict from uuid import UUID from datetime import datetime import json from frozendict import deepfreeze +JSONPrimitive = Union[str, int, float, bool, None] +JSONType = Union[JSONPrimitive, List[Any], Dict[str, Any]] # recursive in practice + class LAMJSONEncoder(json.JSONEncoder): """allows to JSON encode non supported data type""" diff --git a/test/test_model.py b/test/test_appliance.py similarity index 78% rename from test/test_model.py rename to test/test_appliance.py index 83dd715..ac2d3f1 100644 --- a/test/test_model.py +++ b/test/test_appliance.py @@ -41,7 +41,7 @@ def test_initializer_safe_testfc(): eval("print('hi')") -class MainTests(unittest.TestCase): +class ApplianceTest(unittest.TestCase): def setUp(self): print("\n->", unittest.TestCase.id(self)) @@ -370,7 +370,6 @@ class MainTests(unittest.TestCase): with self.assertRaises(AttributeError): app1.testVar.add(3) - # /!\ THIS SHOULD RAISE AN EXCEPTION BUT IT DOESNT :-/ with self.assertRaises(dm.InvalidFieldValue): print("youhou") @@ -630,8 +629,8 @@ class MainTests(unittest.TestCase): app1 = Appliance1() - self.assertIn("__DABSchema__", dir(app1)) - self.assertIn("__DABSchema__", app1.__dict__) + self.assertIn("__LAMSchema__", dir(app1)) + self.assertIn("__LAMSchema__", app1.__dict__) self.check_immutable_fields_schema( app1, "StrVar", "default value", "default value", str @@ -684,8 +683,8 @@ class MainTests(unittest.TestCase): app1 = Appliance1() - self.assertIn("__DABSchema__", dir(app1)) - self.assertIn("__DABSchema__", app1.__dict__) + self.assertIn("__LAMSchema__", dir(app1)) + self.assertIn("__LAMSchema__", app1.__dict__) self.check_immutable_fields_schema( app1, "ListStr", ("val1", "val2"), ("val1", "val2"), list[str] @@ -742,8 +741,8 @@ class MainTests(unittest.TestCase): app1 = Appliance1() - self.assertIn("__DABSchema__", dir(app1)) - self.assertIn("__DABSchema__", app1.__dict__) + self.assertIn("__LAMSchema__", dir(app1)) + self.assertIn("__LAMSchema__", app1.__dict__) self.check_immutable_fields_schema( app1, "ListStr", ("val1", "val2"), ("val1", "val2"), List[str] @@ -1510,6 +1509,23 @@ class MainTests(unittest.TestCase): app2 = _() self.assertEqual(app2.VarInt, 42) + def test_initializer_dont_leak(self): + + class A(dm.Appliance): + VarInt: int = 12 + + class B(A): + + @classmethod + def __initializer(cls): + cls.VarInt = cls.VarInt + 1 + + a = A() + b = B() + + self.assertEqual(a.VarInt, 12) + self.assertEqual(b.VarInt, 13) + def test_initializer_safe(self): with self.assertRaises(dm.ImportForbidden): @@ -1566,10 +1582,10 @@ class MainTests(unittest.TestCase): def __initializer(cls): compile("print(55)", "test", "eval") - with self.assertRaises(dm.FunctionForbidden): + def testfc(): + eval("print('test')") - def testfc(): - eval("print('test')") + with self.assertRaises(dm.FunctionForbidden): class _(dm.Appliance): _: int = 0 @@ -1578,6 +1594,16 @@ class MainTests(unittest.TestCase): def __initializer(cls): testfc() + with self.assertRaises(dm.FunctionForbidden): + + class _(dm.Appliance): + _: int = 0 + + @classmethod + def __initializer(cls): + if True: + testfc() + # class can be created class Appliance2(dm.Appliance): VarInt: int = -56 @@ -1626,144 +1652,6 @@ class MainTests(unittest.TestCase): def __initializer(cls): test_initializer_safe_testfc() - def test_feature(self): - """Testing first appliance feature, and Field types (simple)""" - - # class can be created - class Appliance1(dm.Appliance): - VarStrOuter: str = "testvalue APPLIANCE" - - class Feature1(dm.Feature): - VarStrInner: str = "testvalue FEATURE" - - app1 = Appliance1() - - self.assertIsInstance(Appliance1.__LAMSchema__["VarStrOuter"], dm.LAMField) - self.assertIsInstance(app1.__LAMSchema__["VarStrOuter"], dm.FrozenLAMField) - self.assertIn("Feature1", app1.__LAMSchema__["features"]) - self.assertIn( - "VarStrInner", app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__ - ) - self.assertIsInstance( - app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__["VarStrInner"], - dm.LAMField, - ) - self.assertTrue(hasattr(app1, "Feature1")) - self.assertIsInstance( - app1.Feature1.__LAMSchema__["VarStrInner"], dm.FrozenLAMField - ) - self.assertTrue(hasattr(app1.Feature1, "VarStrInner")) - - def test_feature_inheritance(self): - """Testing first appliance feature, and Field types (simple)""" - - # class can be created - class Appliance1(dm.Appliance): - VarStrOuter: str = "testvalue APPLIANCE1" - - class Feature1(dm.Feature): - VarStrInner: str = "testvalue FEATURE1" - VarInt: int = 42 - - print(dir(Appliance1)) - - class Appliance2(Appliance1): - VarStrOuter = "testvalue APPLIANCE2" - - class Feature2(dm.Feature): - VarStrInner: str = "testvalue FEATURE2" - - print(dir(Appliance2)) - - class Appliance3(Appliance2): - VarStrOuter = "testvalue APPLIANCE3" - - class Feature1(Appliance1.Feature1): - VarStrInner = "testvalue FEATURE1 modded" - - class Feature3(dm.Feature): - VarStrInner: str = "testvalue FEATURE3" - - print(dir(Appliance3)) - - app1 = Appliance1() - app2 = Appliance2() - app3 = Appliance3() - - self.assertIsInstance(Appliance1.__LAMSchema__["VarStrOuter"], dm.LAMField) - self.assertIsInstance(app1.__LAMSchema__["VarStrOuter"], dm.FrozenLAMField) - self.assertIn("Feature1", app1.__LAMSchema__["features"]) - self.assertIn( - "VarStrInner", app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__ - ) - self.assertIsInstance( - app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__["VarStrInner"], - dm.LAMField, - ) - self.assertTrue(hasattr(app1, "Feature1")) - self.assertIsInstance( - app1.Feature1.__LAMSchema__["VarStrInner"], dm.FrozenLAMField - ) - self.assertTrue(hasattr(app1.Feature1, "VarStrInner")) - self.assertEqual(app1.VarStrOuter, "testvalue APPLIANCE1") - self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app1.Feature1.VarInt, 42) - self.assertEqual(app2.VarStrOuter, "testvalue APPLIANCE2") - self.assertEqual(app2.Feature2.VarStrInner, "testvalue FEATURE2") - self.assertEqual(app3.VarStrOuter, "testvalue APPLIANCE3") - self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1 modded") - self.assertEqual(app3.Feature1.VarInt, 42) - self.assertEqual(app3.Feature3.VarStrInner, "testvalue FEATURE3") - - def test_feature_inheritance2(self): - """Testing first appliance feature, and Field types (simple)""" - - # class can be created - class Appliance1(dm.Appliance): - class Feature1(dm.Feature): - VarStrInner: str = "testvalue FEATURE1" - - # check cannot REdefine a feature from Feature - with self.assertRaises(dm.InvalidFeatureInheritance): - - class Appliance2(Appliance1): - class Feature1(dm.Feature): ... - - class Appliance2b(Appliance1): - class Feature1(Appliance1.Feature1): ... - - # check only REdefine a feature from highest parent - with self.assertRaises(dm.InvalidFeatureInheritance): - - class Appliance3(Appliance2b): - class Feature1(Appliance1.Feature1): ... - - class Appliance3b(Appliance2b): - class Feature1(Appliance2b.Feature1): ... - - app1 = Appliance1() - app2 = Appliance2b() - app3 = Appliance3b() - - self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app2.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1") - - class Appliance4(Appliance3b): - class Feature1(Appliance3b.Feature1): - VarStrInner = "testvalue FEATURE4" - - self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app2.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1") - - app4 = Appliance4() - - self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app2.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1") - self.assertEqual(app4.Feature1.VarStrInner, "testvalue FEATURE4") - def test_inheritance_chain(self): # class can be created @@ -1802,49 +1690,6 @@ class MainTests(unittest.TestCase): self.assertEqual(app2b.VarStr, "testvalue1") self.assertEqual(app3b.VarStr, "testvalue1") - def test_feature_register(self): - """Testing first appliance feature, and Field types (simple)""" - - # class can be created - class Appliance1(dm.Appliance): - pass - - class Feature1(dm.Feature): - _BoundAppliance = Appliance1 - VarStrInner: str = "testvalue FEATURE1" - - app = Appliance1(feat1=Feature1) - self.assertEqual(app.feat1.VarStrInner, "testvalue FEATURE1") - - # check it does not leak accross instances - - app = Appliance1(feat2=Feature1) - self.assertEqual(app.feat2.VarStrInner, "testvalue FEATURE1") - - with self.assertRaises(AttributeError): - app.feat1 - - def test_feature_register_notbound(self): - """Testing first appliance feature, and Field types (simple)""" - - # class can be created - class Appliance1(dm.Appliance): - pass - - class Feature1(dm.Feature): - VarStrInner: str = "testvalue FEATURE1" - - with self.assertRaises(dm.FeatureNotBound): - app = Appliance1(feat1=Feature1) - - def test_feature_register_defect(self): - - class Feature1(dm.Feature): - pass - - with self.assertRaises(dm.FeatureNotBound): - feat1 = Feature1() - def test_override(self): """Testing first appliance level, and Field types (List)""" @@ -1891,72 +1736,6 @@ class MainTests(unittest.TestCase): app._internal = 42 # should be allowed self.assertEqual(app._internal, 42) - def test_inherit_declared_feature(self): - class App(dm.Appliance): - class F1(dm.Feature): - val: int = 1 - - class MyF1(App.F1): - val = 2 - val2: str = "toto" - - app = App(F1=MyF1) - self.assertIsInstance(app.F1, MyF1) - self.assertEqual(app.F1.val, 2) - self.assertEqual(app.F1.val2, "toto") - - def test_override_declared_feature(self): - class App(dm.Appliance): - class F1(dm.Feature): - val: int = 1 - val2: str = "toto" - - app = App(F1={"val": 42, "val2": "tata"}) - self.assertEqual(app.F1.val, 42) - self.assertEqual(app.F1.val2, "tata") - - def test_feature_dict_override_type_error(self): - class App(dm.Appliance): - class F1(dm.Feature): - val: int = 1 - - # wrong type for val → must raise InvalidFieldValue - with self.assertRaises(dm.InvalidFieldValue): - App(F1={"val": "not-an-int"}) - - def test_feature_dict_override_nonexisting_field(self): - class App(dm.Appliance): - class F1(dm.Feature): - val: int = 1 - - # field does not exist → must raise - with self.assertRaises(dm.InvalidFieldValue): - App(F1={"doesnotexist": 123}) - - def test_feature_inheritance_with_extra_fields(self): - class App(dm.Appliance): - class F1(dm.Feature): - val: int = 1 - - class MyF1(App.F1): - val = 2 - extra: str = "hello" - - app = App(F1=MyF1) - self.assertEqual(app.F1.val, 2) - self.assertEqual(app.F1.extra, "hello") - - def test_feature_not_bound_runtime_attach_fails(self): - class App(dm.Appliance): - pass - - class UnboundFeature(dm.Feature): - x: int = 1 - - # attaching an unbound feature should raise - with self.assertRaises(dm.FeatureNotBound): - App(Unbound=UnboundFeature) - def test_field_cannot_be_reassigned(self): class App(dm.Appliance): x: int = 1 @@ -1965,16 +1744,6 @@ class MainTests(unittest.TestCase): with self.assertRaises(dm.ReadOnlyField): app.x = 99 - def test_feature_override_does_not_leak_between_instances(self): - class App(dm.Appliance): - class F1(dm.Feature): - val: int = 1 - - app1 = App(F1={"val": 99}) - app2 = App() - self.assertEqual(app1.F1.val, 99) - self.assertEqual(app2.F1.val, 1) - def test_schema_fields_are_frozen(self): class App(dm.Appliance): x: list[int] = [1, 2] @@ -2023,16 +1792,29 @@ class MainTests(unittest.TestCase): cls.x = inner() - def test_initializer_lambda_forbidden(self): with self.assertRaises(dm.FunctionForbidden): - class App(dm.Appliance): + class _(dm.Appliance): x: int = 1 @classmethod def __initializer(cls): - f = lambda: 42 # forbidden - cls.x = f() + y = 2 + + def inner(): + return y + 1 + + cls.x = lambda: inner() + + def test_initializer_lambda_forbidden(self): + + class App(dm.Appliance): + x: int = 1 + + @classmethod + def __initializer(cls): + f = lambda: 42 + cls.x = f() def test_multiple_inheritance_forbidden(self): with self.assertRaises(dm.MultipleInheritanceForbidden): @@ -2197,61 +1979,6 @@ class MainTests(unittest.TestCase): with self.assertRaises(dm.InvalidFieldValue): App(a=["oops"]) # wrong inner type - def test_runtime_attach_bound_feature_success(self): - class App(dm.Appliance): - class F1(dm.Feature): - 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.Appliance): - class F1(dm.Feature): - 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.Appliance): - class F(dm.Feature): - 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.Appliance): - class F(dm.Feature): - 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.Appliance): x: int = 1 @@ -2259,227 +1986,6 @@ class MainTests(unittest.TestCase): 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.Appliance): - class Feat1(dm.Feature): - 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* Feature, not subclass of B.Feat1 - with self.assertRaises(dm.InvalidFeatureInheritance): - - class D(B): - class Feat1(dm.Feature): - 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.Feature): - 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.Appliance): - class Feat1(dm.Feature): - 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.Appliance): - class F1(dm.Feature): - a: int = 1 - - class F2(dm.Feature): - 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.Feature): - 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.Appliance): - class F1(dm.Feature): - 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.Appliance): - class F1(dm.Feature): - 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")) - def test_dict_field_override_with_nested_containers(self): class App(dm.Appliance): data: dict[str, list[int]] = {"numbers": [1, 2]} @@ -2494,18 +2000,6 @@ class MainTests(unittest.TestCase): with self.assertRaises(dm.InvalidFieldValue): App(data={"numbers": [1, "oops"]}) - def test_feature_dict_override_with_nested_containers(self): - class App(dm.Appliance): - class F1(dm.Feature): - values: list[int] = [1, 2] - - app = App(F1={"values": [5, 6]}) - self.assertEqual(app.F1.values, (5, 6)) # deepfreeze → tuple - - # Invalid type in list should fail - with self.assertRaises(dm.InvalidFieldValue): - App(F1={"values": [1, "oops"]}) - def test_initializer_modifies_nested_containers(self): class App(dm.Appliance): data: dict[str, list[int]] = {"nums": [1]} @@ -2518,61 +2012,6 @@ class MainTests(unittest.TestCase): app = App() self.assertEqual(app.data["nums"], (1, 2)) # frozen tuple after init - def test_feature_dict_override_with_unknown_key(self): - class App(dm.Appliance): - class F1(dm.Feature): - a: int = 1 - - # Dict override with unknown field 'zzz' - with self.assertRaises(dm.InvalidFieldValue): - App(F1={"zzz": 42}) - - def test_schema_isolation_across_multiple_feature_overrides(self): - class App(dm.Appliance): - class F1(dm.Feature): - a: int = 1 - - class F1a(App.F1): - a = 10 - - class F1b(App.F1): - a = 20 - - app1 = App(F1=F1a) - self.assertIsInstance(app1.F1, F1a) - self.assertEqual(app1.F1.a, 10) - - app2 = App(F1=F1b) - self.assertIsInstance(app2.F1, F1b) - self.assertEqual(app2.F1.a, 20) - - # Original appliance schema must not be polluted - app3 = App() - self.assertIsInstance(app3.F1, App.F1) - self.assertEqual(app3.F1.a, 1) - - def test_feature_inheritance_with_annotated_fields(self): - from typing_extensions import Annotated - - class App(dm.Appliance): - class F1(dm.Feature): - a: Annotated[int, dm.LAMFieldInfo(doc="field a")] = 1 - - # ✅ Subclass override must inherit from parent F1 - class F1Ex(App.F1): - b: str = "ok" - - app = App(F1=F1Ex) - self.assertIsInstance(app.F1, F1Ex) - self.assertEqual((app.F1.a, app.F1.b), (1, "ok")) - - # ❌ Wrong: fresh Feature under same name - with self.assertRaises(dm.InvalidFeatureInheritance): - - class Bad(App): - class F1(dm.Feature): - fail: str = "oops" - # ---------- main ---------- diff --git a/test/test_element.py b/test/test_element.py new file mode 100644 index 0000000..952e3cf --- /dev/null +++ b/test/test_element.py @@ -0,0 +1,293 @@ +# dabmodel (c) by chacha +# +# dabmodel is licensed under a +# Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Unported License. +# +# You should have received a copy of the license along with this +# work. If not, see . + +import unittest + +import sys +import subprocess +from os import chdir, environ +from pathlib import Path + +print(__name__) +print(__package__) + +from src import dabmodel as dm + +testdir_path = Path(__file__).parent.resolve() +chdir(testdir_path.parent.resolve()) + + +class ElementTest(unittest.TestCase): + def setUp(self): + print("\n->", unittest.TestCase.id(self)) + + def test_element_simple(self): + class E(dm.Element): + ivalue: int = 43 + strvalue: str = "test" + fvalue: float = 1.4322 + ar_int: list[int] = [1, 54, 65] + ar_int2: list[int] = [1, 54, 65] + + class A(dm.Appliance): + elem: E = E(ivalue=45, strvalue="coucou", ar_int=[5, 7]) + + a = A() + + self.assertIsInstance(a.elem, E) + self.assertIsInstance(a.elem.ivalue, int) + self.assertEqual(a.elem.ivalue, 45) + self.assertIsInstance(a.elem.strvalue, str) + self.assertEqual(a.elem.strvalue, "coucou") + self.assertIsInstance(a.elem.fvalue, float) + self.assertEqual(a.elem.fvalue, 1.4322) + self.assertIsInstance(a.elem.ar_int, tuple) + self.assertEqual(a.elem.ar_int, (5, 7)) + self.assertIsInstance(a.elem.ar_int2, tuple) + self.assertEqual(a.elem.ar_int2, (1, 54, 65)) + + def test_element_in_container(self): + class E(dm.Element): + ivalue: int = 43 + strvalue: str = "test" + fvalue: float = 1.4322 + ar_int: list[int] = [1, 54, 65] + ar_int2: list[int] = [1, 54, 65] + + class A(dm.Appliance): + elems: list[E] = [ + E(ivalue=45, strvalue="coucou", ar_int=[5, 7]), + E(ivalue=46, strvalue="coucou2", ar_int=[50, 7]), + ] + + a = A() + + self.assertIsInstance(a.elems, tuple) + self.assertEqual(len(a.elems), 2) + + self.assertIsInstance(a.elems[0], E) + self.assertIsInstance(a.elems[0].ivalue, int) + self.assertEqual(a.elems[0].ivalue, 45) + self.assertIsInstance(a.elems[0].strvalue, str) + self.assertEqual(a.elems[0].strvalue, "coucou") + self.assertIsInstance(a.elems[0].fvalue, float) + self.assertEqual(a.elems[0].fvalue, 1.4322) + self.assertIsInstance(a.elems[0].ar_int, tuple) + self.assertEqual(a.elems[0].ar_int, (5, 7)) + self.assertIsInstance(a.elems[0].ar_int2, tuple) + self.assertEqual(a.elems[0].ar_int2, (1, 54, 65)) + + self.assertIsInstance(a.elems[1], E) + self.assertIsInstance(a.elems[1].ivalue, int) + self.assertEqual(a.elems[1].ivalue, 46) + self.assertIsInstance(a.elems[1].strvalue, str) + self.assertEqual(a.elems[1].strvalue, "coucou2") + self.assertIsInstance(a.elems[1].fvalue, float) + self.assertEqual(a.elems[1].fvalue, 1.4322) + self.assertIsInstance(a.elems[1].ar_int, tuple) + self.assertEqual(a.elems[1].ar_int, (50, 7)) + self.assertIsInstance(a.elems[1].ar_int2, tuple) + self.assertEqual(a.elems[1].ar_int2, (1, 54, 65)) + + def test_element_frozen(self): + class E(dm.Element): + ivalue: int = 43 + strvalue: str = "test" + fvalue: float = 1.4322 + ar_int: list[int] = [1, 54, 65] + ar_int2: list[int] = [1, 54, 65] + + class A(dm.Appliance): + elems: list[E] = [ + E(ivalue=45, strvalue="coucou", ar_int=[5, 7]), + E(ivalue=46, strvalue="coucou2", ar_int=[50, 7]), + ] + elem: E = E() + + a = A() + + with self.assertRaises(AttributeError): + a.elems.add(E()) + + with self.assertRaises(dm.ReadOnlyField): + a.elem.ivalue = 1 + + with self.assertRaises(dm.ReadOnlyField): + a.elems[0].ivalue = 1 + + def test_element_inheritance(self): + class E(dm.Element): + ivalue: int = 43 + strvalue: str = "test" + fvalue: float = 1.4322 + ar_int: list[int] = [1, 54, 65] + ar_int2: list[int] = [1, 54, 65] + + class E2(E): + ivalue2: int = 43 + + class A(dm.Appliance): + elems: list[E] = [ + E(ivalue=45, strvalue="coucou", ar_int=[5, 7]), + E2(ivalue=46, strvalue="coucou2", ar_int=[50, 7], ivalue2=32), + ] + elem: E = E() + elem2: E2 = E2(ivalue=7, ivalue2=33) + + a = A() + + self.assertIsInstance(a.elems, tuple) + self.assertEqual(len(a.elems), 2) + + self.assertIsInstance(a.elems[0], E) + self.assertIsInstance(a.elems[0].ivalue, int) + self.assertEqual(a.elems[0].ivalue, 45) + self.assertIsInstance(a.elems[0].strvalue, str) + self.assertEqual(a.elems[0].strvalue, "coucou") + self.assertIsInstance(a.elems[0].fvalue, float) + self.assertEqual(a.elems[0].fvalue, 1.4322) + self.assertIsInstance(a.elems[0].ar_int, tuple) + self.assertEqual(a.elems[0].ar_int, (5, 7)) + self.assertIsInstance(a.elems[0].ar_int2, tuple) + self.assertEqual(a.elems[0].ar_int2, (1, 54, 65)) + + self.assertIsInstance(a.elems[1], E2) + self.assertIsInstance(a.elems[1].ivalue, int) + self.assertEqual(a.elems[1].ivalue, 46) + self.assertIsInstance(a.elems[1].ivalue2, int) + self.assertEqual(a.elems[1].ivalue2, 32) + self.assertIsInstance(a.elems[1].strvalue, str) + self.assertEqual(a.elems[1].strvalue, "coucou2") + self.assertIsInstance(a.elems[1].fvalue, float) + self.assertEqual(a.elems[1].fvalue, 1.4322) + self.assertIsInstance(a.elems[1].ar_int, tuple) + self.assertEqual(a.elems[1].ar_int, (50, 7)) + self.assertIsInstance(a.elems[1].ar_int2, tuple) + self.assertEqual(a.elems[1].ar_int2, (1, 54, 65)) + + self.assertIsInstance(a.elem, E) + self.assertIsInstance(a.elem.ivalue, int) + self.assertEqual(a.elem.ivalue, 43) + self.assertIsInstance(a.elem.strvalue, str) + self.assertEqual(a.elem.strvalue, "test") + self.assertIsInstance(a.elem.fvalue, float) + self.assertEqual(a.elem.fvalue, 1.4322) + self.assertIsInstance(a.elem.ar_int, tuple) + self.assertEqual(a.elem.ar_int, (1, 54, 65)) + self.assertIsInstance(a.elem.ar_int2, tuple) + self.assertEqual(a.elem.ar_int2, (1, 54, 65)) + + self.assertIsInstance(a.elem2, E2) + self.assertIsInstance(a.elem2.ivalue, int) + self.assertEqual(a.elem2.ivalue, 7) + self.assertIsInstance(a.elem2.ivalue2, int) + self.assertEqual(a.elem2.ivalue2, 33) + self.assertIsInstance(a.elem2.strvalue, str) + self.assertEqual(a.elem2.strvalue, "test") + self.assertIsInstance(a.elem2.fvalue, float) + self.assertEqual(a.elem2.fvalue, 1.4322) + self.assertIsInstance(a.elem2.ar_int, tuple) + self.assertEqual(a.elem2.ar_int, (1, 54, 65)) + self.assertIsInstance(a.elem2.ar_int2, tuple) + self.assertEqual(a.elem2.ar_int2, (1, 54, 65)) + + def test_element_initializer(self): + class E(dm.Element): + ivalue: int = 43 + strvalue: str = "test" + fvalue: float = 1.4322 + ar_int: list[int] = [1, 54, 65] + ar_int2: list[int] = [1, 54, 65] + + class A(dm.Appliance): + elem: E = E(ivalue=45, strvalue="coucou", ar_int=[5, 7]) + + @classmethod + def __initializer(self): + self.elem = E(ivalue=12, strvalue="coucou", ar_int=[5, 7]) + + a = A() + + self.assertIsInstance(a.elem, E) + self.assertIsInstance(a.elem.ivalue, int) + self.assertEqual(a.elem.ivalue, 12) + self.assertIsInstance(a.elem.strvalue, str) + self.assertEqual(a.elem.strvalue, "coucou") + self.assertIsInstance(a.elem.fvalue, float) + self.assertEqual(a.elem.fvalue, 1.4322) + self.assertIsInstance(a.elem.ar_int, tuple) + self.assertEqual(a.elem.ar_int, (5, 7)) + self.assertIsInstance(a.elem.ar_int2, tuple) + self.assertEqual(a.elem.ar_int2, (1, 54, 65)) + + def test_element_in_container_initializer(self): + class E(dm.Element): + ivalue: int = 43 + strvalue: str = "test" + fvalue: float = 1.4322 + ar_int: list[int] = [1, 54, 65] + ar_int2: list[int] = [1, 54, 65] + + class A(dm.Appliance): + elems: list[E] = [E(ivalue=45, strvalue="coucou", ar_int=[5, 7])] + + class B(A): + @classmethod + def __initializer(cls): + cls.elems.append(E(ivalue=46, strvalue="coucou2", ar_int=[50, 7])) + + a = A() + b = B() + + self.assertIsInstance(a.elems, tuple) + self.assertEqual(len(a.elems), 1) + + self.assertIsInstance(a.elems[0], E) + self.assertIsInstance(a.elems[0].ivalue, int) + self.assertEqual(a.elems[0].ivalue, 45) + self.assertIsInstance(a.elems[0].strvalue, str) + self.assertEqual(a.elems[0].strvalue, "coucou") + self.assertIsInstance(a.elems[0].fvalue, float) + self.assertEqual(a.elems[0].fvalue, 1.4322) + self.assertIsInstance(a.elems[0].ar_int, tuple) + self.assertEqual(a.elems[0].ar_int, (5, 7)) + self.assertIsInstance(a.elems[0].ar_int2, tuple) + self.assertEqual(a.elems[0].ar_int2, (1, 54, 65)) + + self.assertIsInstance(b.elems, tuple) + self.assertEqual(len(b.elems), 2) + + self.assertIsInstance(b.elems[0], E) + self.assertIsInstance(b.elems[0].ivalue, int) + self.assertEqual(b.elems[0].ivalue, 45) + self.assertIsInstance(b.elems[0].strvalue, str) + self.assertEqual(b.elems[0].strvalue, "coucou") + self.assertIsInstance(b.elems[0].fvalue, float) + self.assertEqual(b.elems[0].fvalue, 1.4322) + self.assertIsInstance(b.elems[0].ar_int, tuple) + self.assertEqual(b.elems[0].ar_int, (5, 7)) + self.assertIsInstance(b.elems[0].ar_int2, tuple) + self.assertEqual(b.elems[0].ar_int2, (1, 54, 65)) + + self.assertIsInstance(b.elems[1], E) + self.assertIsInstance(b.elems[1].ivalue, int) + self.assertEqual(b.elems[1].ivalue, 46) + self.assertIsInstance(b.elems[1].strvalue, str) + self.assertEqual(b.elems[1].strvalue, "coucou2") + self.assertIsInstance(b.elems[1].fvalue, float) + self.assertEqual(b.elems[1].fvalue, 1.4322) + self.assertIsInstance(b.elems[1].ar_int, tuple) + self.assertEqual(b.elems[1].ar_int, (50, 7)) + self.assertIsInstance(b.elems[1].ar_int2, tuple) + self.assertEqual(b.elems[1].ar_int2, (1, 54, 65)) + + +# ---------- main ---------- + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_feature.py b/test/test_feature.py new file mode 100644 index 0000000..1b95d6c --- /dev/null +++ b/test/test_feature.py @@ -0,0 +1,730 @@ +# dabmodel (c) by chacha +# +# dabmodel is licensed under a +# Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Unported License. +# +# You should have received a copy of the license along with this +# work. If not, see . + +import unittest + +from os import chdir +from pathlib import Path + +from typing import ( + Any, + Annotated, +) + + +print(__name__) +print(__package__) + +from src import dabmodel as dm + + +testdir_path = Path(__file__).parent.resolve() +chdir(testdir_path.parent.resolve()) + + +def test_initializer_safe_testfc(): + eval("print('hi')") + + +class FeatureTest(unittest.TestCase): + def setUp(self): + print("\n->", unittest.TestCase.id(self)) + + def immutable_vars__test_field( + self, obj: Any, name: str, default_value: Any, test_value: Any + ): + # field is not in the class + self.assertNotIn(name, dir(obj.__class__)) + # field is in the object + self.assertIn(name, dir(obj)) + # field is in the schema + self.assertIn(name, obj.__LAMSchema__.keys()) + # field is readable + self.assertEqual(getattr(obj, name), default_value) + # field is read only + with self.assertRaises(dm.ReadOnlyField): + setattr(obj, name, test_value) + + def test_simple(self): + """Testing first appliance feature, and Field types (simple)""" + + # class can be created + class Appliance1(dm.Appliance): + VarStrOuter: str = "testvalue APPLIANCE" + + class Feature1(dm.Feature): + VarStrInner: str = "testvalue FEATURE" + + app1 = Appliance1() + + self.assertIsInstance(Appliance1.__LAMSchema__["VarStrOuter"], dm.LAMField) + self.assertIsInstance(app1.__LAMSchema__["VarStrOuter"], dm.FrozenLAMField) + self.assertIn("Feature1", app1.__LAMSchema__["features"]) + self.assertIn( + "VarStrInner", app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__ + ) + self.assertIsInstance( + app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__["VarStrInner"], + dm.LAMField, + ) + self.assertTrue(hasattr(app1, "Feature1")) + self.assertIsInstance( + app1.Feature1.__LAMSchema__["VarStrInner"], dm.FrozenLAMField + ) + self.assertTrue(hasattr(app1.Feature1, "VarStrInner")) + + def test_inheritance(self): + """Testing first appliance feature, and Field types (simple)""" + + # class can be created + class Appliance1(dm.Appliance): + VarStrOuter: str = "testvalue APPLIANCE1" + + class Feature1(dm.Feature): + VarStrInner: str = "testvalue FEATURE1" + VarInt: int = 42 + + print(dir(Appliance1)) + + class Appliance2(Appliance1): + VarStrOuter = "testvalue APPLIANCE2" + + class Feature2(dm.Feature): + VarStrInner: str = "testvalue FEATURE2" + + print(dir(Appliance2)) + + class Appliance3(Appliance2): + VarStrOuter = "testvalue APPLIANCE3" + + class Feature1(Appliance1.Feature1): + VarStrInner = "testvalue FEATURE1 modded" + + class Feature3(dm.Feature): + VarStrInner: str = "testvalue FEATURE3" + + print(dir(Appliance3)) + + app1 = Appliance1() + app2 = Appliance2() + app3 = Appliance3() + + self.assertIsInstance(Appliance1.__LAMSchema__["VarStrOuter"], dm.LAMField) + self.assertIsInstance(app1.__LAMSchema__["VarStrOuter"], dm.FrozenLAMField) + self.assertIn("Feature1", app1.__LAMSchema__["features"]) + self.assertIn( + "VarStrInner", app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__ + ) + self.assertIsInstance( + app1.__LAMSchema__["features"]["Feature1"].__LAMSchema__["VarStrInner"], + dm.LAMField, + ) + self.assertTrue(hasattr(app1, "Feature1")) + self.assertIsInstance( + app1.Feature1.__LAMSchema__["VarStrInner"], dm.FrozenLAMField + ) + self.assertTrue(hasattr(app1.Feature1, "VarStrInner")) + self.assertEqual(app1.VarStrOuter, "testvalue APPLIANCE1") + self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app1.Feature1.VarInt, 42) + self.assertEqual(app2.VarStrOuter, "testvalue APPLIANCE2") + self.assertEqual(app2.Feature2.VarStrInner, "testvalue FEATURE2") + self.assertEqual(app3.VarStrOuter, "testvalue APPLIANCE3") + self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1 modded") + self.assertEqual(app3.Feature1.VarInt, 42) + self.assertEqual(app3.Feature3.VarStrInner, "testvalue FEATURE3") + + def test_inheritance2(self): + """Testing first appliance feature, and Field types (simple)""" + + # class can be created + class Appliance1(dm.Appliance): + class Feature1(dm.Feature): + VarStrInner: str = "testvalue FEATURE1" + + # check cannot REdefine a feature from Feature + with self.assertRaises(dm.InvalidFeatureInheritance): + + class Appliance2(Appliance1): + class Feature1(dm.Feature): ... + + class Appliance2b(Appliance1): + class Feature1(Appliance1.Feature1): ... + + # check only REdefine a feature from highest parent + with self.assertRaises(dm.InvalidFeatureInheritance): + + class Appliance3(Appliance2b): + class Feature1(Appliance1.Feature1): ... + + class Appliance3b(Appliance2b): + class Feature1(Appliance2b.Feature1): ... + + app1 = Appliance1() + app2 = Appliance2b() + app3 = Appliance3b() + + self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app2.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1") + + class Appliance4(Appliance3b): + class Feature1(Appliance3b.Feature1): + VarStrInner = "testvalue FEATURE4" + + self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app2.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1") + + app4 = Appliance4() + + self.assertEqual(app1.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app2.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app3.Feature1.VarStrInner, "testvalue FEATURE1") + self.assertEqual(app4.Feature1.VarStrInner, "testvalue FEATURE4") + + def test_register(self): + """Testing first appliance feature, and Field types (simple)""" + + # class can be created + class Appliance1(dm.Appliance): + pass + + class Feature1(dm.Feature): + _BoundAppliance = Appliance1 + VarStrInner: str = "testvalue FEATURE1" + + app = Appliance1(feat1=Feature1) + self.assertEqual(app.feat1.VarStrInner, "testvalue FEATURE1") + + # check it does not leak accross instances + + app = Appliance1(feat2=Feature1) + self.assertEqual(app.feat2.VarStrInner, "testvalue FEATURE1") + + with self.assertRaises(AttributeError): + app.feat1 + + def test_register_notbound(self): + """Testing first appliance feature, and Field types (simple)""" + + # class can be created + class Appliance1(dm.Appliance): + pass + + class Feature1(dm.Feature): + VarStrInner: str = "testvalue FEATURE1" + + with self.assertRaises(dm.FeatureNotBound): + Appliance1(feat1=Feature1) + + def test_register_defect(self): + + class Feature1(dm.Feature): + pass + + with self.assertRaises(dm.FeatureNotBound): + Feature1() + + def test_new_field_forbidden(self): + class App(dm.Appliance): + x: int = 1 + + app = App() + with self.assertRaises(dm.NewFieldForbidden): + app.y = 2 + + def test_inherit_declared(self): + class App(dm.Appliance): + class F1(dm.Feature): + val: int = 1 + + class MyF1(App.F1): + val = 2 + val2: str = "toto" + + app = App(F1=MyF1) + self.assertIsInstance(app.F1, MyF1) + self.assertEqual(app.F1.val, 2) + self.assertEqual(app.F1.val2, "toto") + + def test_override_declared(self): + class App(dm.Appliance): + class F1(dm.Feature): + val: int = 1 + val2: str = "toto" + + app = App(F1={"val": 42, "val2": "tata"}) + self.assertEqual(app.F1.val, 42) + self.assertEqual(app.F1.val2, "tata") + + def test_dict_override_type_error(self): + class App(dm.Appliance): + class F1(dm.Feature): + val: int = 1 + + # wrong type for val → must raise InvalidFieldValue + with self.assertRaises(dm.InvalidFieldValue): + App(F1={"val": "not-an-int"}) + + def test_dict_override_nonexisting_field(self): + class App(dm.Appliance): + class F1(dm.Feature): + val: int = 1 + + # field does not exist → must raise + with self.assertRaises(dm.InvalidFieldValue): + App(F1={"doesnotexist": 123}) + + def test_inheritance_with_extra_fields(self): + class App(dm.Appliance): + class F1(dm.Feature): + val: int = 1 + + class MyF1(App.F1): + val = 2 + extra: str = "hello" + + app = App(F1=MyF1) + self.assertEqual(app.F1.val, 2) + self.assertEqual(app.F1.extra, "hello") + + def test_not_bound_runtime_attach_fails(self): + class App(dm.Appliance): + pass + + class UnboundFeature(dm.Feature): + x: int = 1 + + # attaching an unbound feature should raise + with self.assertRaises(dm.FeatureNotBound): + App(Unbound=UnboundFeature) + + def test_override_does_not_leak_between_instances(self): + class App(dm.Appliance): + class F1(dm.Feature): + val: int = 1 + + app1 = App(F1={"val": 99}) + app2 = App() + self.assertEqual(app1.F1.val, 99) + self.assertEqual(app2.F1.val, 1) + + def test_deepfreeze_nested_mixed_tuple_list(self): + class App(dm.Appliance): + 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.Appliance): + SomeVar: list = [] + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance2(dm.Appliance): + SomeVar: list[Any] = [] + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance3(dm.Appliance): + SomeVar: list[object] = [] + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance4(dm.Appliance): + SomeVar: dict = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance5(dm.Appliance): + SomeVar: dict[str, Any] = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance6(dm.Appliance): + SomeVar: dict[Any, Any] = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance7(dm.Appliance): + SomeVar: dict[Any, str] = {} + + with self.assertRaises(dm.InvalidFieldAnnotation): + + class Appliance8(dm.Appliance): + SomeVar: dict[str, object] = {} + + def test_runtime_attach_bound_success(self): + class App(dm.Appliance): + class F1(dm.Feature): + 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_annotation(self): + class App(dm.Appliance): + class F1(dm.Feature): + val: int = 1 + + with self.assertRaises(dm.ReadOnlyFieldAnnotation): + + class Extra(App.F1): + val: str = "test" + + def test_fields_are_frozen_after_override(self): + class App(dm.Appliance): + class F(dm.Feature): + 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_dict_partial_override_keeps_other_defaults(self): + class App(dm.Appliance): + class F(dm.Feature): + 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_override_linear_chain(self): + # Base appliance defines Feat1 + class A(dm.Appliance): + class Feat1(dm.Feature): + 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* Feature, not subclass of B.Feat1 + with self.assertRaises(dm.InvalidFeatureInheritance): + + class D(B): + class Feat1(dm.Feature): + 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.Feature): + other: str = "ok" + + self.assertTrue(hasattr(F, "Feat2")) + + def test_override_chain_runtime_replacement(self): + # Build a linear chain: A -> B -> C for feature 'Feat1' + class A(dm.Appliance): + class Feat1(dm.Feature): + 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_inheritance_tree_and_no_leakage(self): + class A(dm.Appliance): + class F1(dm.Feature): + a: int = 1 + + class F2(dm.Feature): + 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.Feature): + 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_isolation(self): + class A(dm.Appliance): + class F1(dm.Feature): + 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.Appliance): + class F1(dm.Feature): + 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")) + + def test_feature_dict_override_with_nested_containers(self): + class App(dm.Appliance): + class F1(dm.Feature): + values: list[int] = [1, 2] + + app = App(F1={"values": [5, 6]}) + self.assertEqual(app.F1.values, (5, 6)) # deepfreeze → tuple + + # Invalid type in list should fail + with self.assertRaises(dm.InvalidFieldValue): + App(F1={"values": [1, "oops"]}) + + def test_dict_override_with_unknown_key(self): + class App(dm.Appliance): + class F1(dm.Feature): + a: int = 1 + + # Dict override with unknown field 'zzz' + with self.assertRaises(dm.InvalidFieldValue): + App(F1={"zzz": 42}) + + def test_schema_isolation_across_multiple_overrides(self): + class App(dm.Appliance): + class F1(dm.Feature): + a: int = 1 + + class F1a(App.F1): + a = 10 + + class F1b(App.F1): + a = 20 + + app1 = App(F1=F1a) + self.assertIsInstance(app1.F1, F1a) + self.assertEqual(app1.F1.a, 10) + + app2 = App(F1=F1b) + self.assertIsInstance(app2.F1, F1b) + self.assertEqual(app2.F1.a, 20) + + # Original appliance schema must not be polluted + app3 = App() + self.assertIsInstance(app3.F1, App.F1) + self.assertEqual(app3.F1.a, 1) + + def test_inheritance_with_annotated_fields(self): + + class App(dm.Appliance): + class F1(dm.Feature): + a: Annotated[int, dm.LAMFieldInfo(doc="field a")] = 1 + + # ✅ Subclass override must inherit from parent F1 + class F1Ex(App.F1): + b: str = "ok" + + app = App(F1=F1Ex) + self.assertIsInstance(app.F1, F1Ex) + self.assertEqual((app.F1.a, app.F1.b), (1, "ok")) + + # ❌ Wrong: fresh Feature under same name + with self.assertRaises(dm.InvalidFeatureInheritance): + + class Bad(App): + class F1(dm.Feature): + fail: str = "oops" + + +# ---------- main ---------- + +if __name__ == "__main__": + unittest.main()