diff --git a/src/dabmodel/__init__.py b/src/dabmodel/__init__.py index f2ebfb1..5cbe0b4 100644 --- a/src/dabmodel/__init__.py +++ b/src/dabmodel/__init__.py @@ -11,4 +11,17 @@ Main module __init__ file. """ from .__metadata__ import __version__, __Summuary__, __Name__ -from .model import BaseAppliance, BaseFeature +from .model import ( + BaseAppliance, + BaseFeature, + DABModelException, + MultipleInheritanceForbidden, + BrokenInheritance, + ReadOnlyField, + NewFieldForbidden, + NotAnnotatedField, + ReadOnlyFieldAnnotation, + InvalidFieldValue, + InvalidFieldAnnotation, + IncompletelyAnnotatedField, +) diff --git a/src/dabmodel/model.py b/src/dabmodel/model.py index 82727a7..1fb2322 100644 --- a/src/dabmodel/model.py +++ b/src/dabmodel/model.py @@ -1,61 +1,116 @@ -# from __future__ import annotations +from typing import Optional, TypeVar, Generic, Union, get_origin, get_args, List, Dict, Any, Tuple, Set, Annotated, FrozenSet +from types import UnionType +from frozendict import deepfreeze +from copy import deepcopy, copy +from pprint import pprint -from typing import Optional, TypeVar, Generic, Union, get_origin, get_args, List, Dict, Any +from typeguard import check_type, TypeCheckError, CollectionCheckStrategy -from pprintpp import pprint -from copy import deepcopy +ALLOWED_ANNOTATIONS = { + "Union": Union, + "Optional": Optional, + "List": List, + "Dict": Dict, + "Tuple": Tuple, + "Set": Set, + "FrozenSet": FrozenSet, + "Annotated": Annotated, + # builtins: + "int": int, + "str": str, + "float": float, + "bool": bool, + "complex": complex, + "bytes": bytes, + "None": type(None), + "list": list, + "dict": dict, + "set": set, + "frozenset": frozenset, + "tuple": tuple, +} - -from typeguard import check_type, TypeCheckError -from types import MappingProxyType - -ALLOWED_MODEL_FIELDS_TYPES = (str, int, float, complex, bool, bytearray) -ALLOWED_MODEL_FIELDS_CONTAINERS = (dict, list, set, frozenset) +ALLOWED_MODEL_FIELDS_TYPES = (str, int, float, complex, bool, bytes) +ALLOWED_MODEL_FIELDS_CONTAINERS = (dict, list, set, frozenset, tuple) TV_ALLOWED_MODEL_FIELDS_TYPES = TypeVar("TV_ALLOWED_MODEL_FIELDS_TYPES", *ALLOWED_MODEL_FIELDS_TYPES, *ALLOWED_MODEL_FIELDS_CONTAINERS) -class DABModelSchema: ... +class DABModelException(Exception): ... -def __check_type__(_type) -> bool: +class MultipleInheritanceForbidden(DABModelException): ... + + +class BrokenInheritance(DABModelException): ... + + +class ReadOnlyField(DABModelException): ... + + +class NewFieldForbidden(DABModelException): ... + + +class InvalidFieldAnnotation(DABModelException): ... + + +class NotAnnotatedField(InvalidFieldAnnotation): ... + + +class IncompletelyAnnotatedField(InvalidFieldAnnotation): ... + + +class ReadOnlyFieldAnnotation(DABModelException): ... + + +class InvalidFieldValue(DABModelException): ... + + +def _resolve_annotation(ann): + if isinstance(ann, str): + # Safe eval against a **whitelist** only + return eval(ann, {"__builtins__": {}}, ALLOWED_ANNOTATIONS) + return ann + + +def _peel_annotated(t: Any) -> Any: + # If you ever allow Annotated[T, ...], peel to T + while True: + origin = get_origin(t) + if origin is None: + return t + name = getattr(origin, "__name__", "") or getattr(origin, "__qualname__", "") or str(origin) + if "Annotated" in name: + args = get_args(t) + t = args[0] if args else t + else: + return t + + +def __check_annotation_definition__(_type) -> bool: + _type = _peel_annotated(_type) # handle Optional[] and Union[None,...] - if get_origin(_type) is Union and type(None) in get_args(_type): - res = [] - for inner_type in [_ for _ in get_args(_type) if _ is not type(None)]: - res.append(__check_type__(inner_type)) - return all(res) + if (get_origin(_type) is Union or get_origin(_type) is UnionType) and type(None) in get_args(_type): + return all([__check_annotation_definition__(_) for _ in get_args(_type) if _ is not type(None)]) - # handle List[...] - if get_origin(_type) is list: - inner = get_args(_type) - if len(inner) != 1: - return False - return __check_type__(inner) + # 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)]) # handle Dict[...] if get_origin(_type) is dict: inner = get_args(_type) if len(inner) != 2: - return False - key = inner[0] in ALLOWED_MODEL_FIELDS_TYPES - val = __check_type__(inner[1]) - return key and val + 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]) - # handle tuple (,) - if type(_type) is tuple: - res = [] - for inner_type in _type: - res.append(__check_type__(inner_type)) - return all(res) - - # handle Set[],Tuple[],FrozenSet[] - if get_origin(_type) in [set, frozenset, tuple]: - res = [] - for inner_type in get_args(_type): - res.append(__check_type__(inner_type)) - return all(res) + # handle Set[],Tuple[],FrozenSet[],List[] + if get_origin(_type) in [set, frozenset, tuple, list]: + inner_types = get_args(_type) + if len(inner_types) == 0: + raise IncompletelyAnnotatedField(f"Annotation requires inner definition: {_type}") + return all([__check_annotation_definition__(_) for _ in inner_types]) if _type in ALLOWED_MODEL_FIELDS_TYPES: return True @@ -65,10 +120,24 @@ def __check_type__(_type) -> bool: class Constraint: ... +def _deepfreeze(value): + if isinstance(value, dict): + return deepfreeze(value) + elif isinstance(value, set): + return frozenset(_deepfreeze(v) for v in value) + elif isinstance(value, list): + return tuple(_deepfreeze(v) for v in value) + elif isinstance(value, tuple): + return tuple(_deepfreeze(v) for v in value) + return value + + class DABField(Generic[TV_ALLOWED_MODEL_FIELDS_TYPES]): def __init__(self, name: str, v: Optional[TV_ALLOWED_MODEL_FIELDS_TYPES], a: Any): self._constraints: List[Constraint] = [] self._name: str = name + self._source: Optional[type] = None + self._default_value: Optional[TV_ALLOWED_MODEL_FIELDS_TYPES] = v self._value: Optional[TV_ALLOWED_MODEL_FIELDS_TYPES] = v self._annotations: Any = a self._documentation: str = "" @@ -76,6 +145,9 @@ class DABField(Generic[TV_ALLOWED_MODEL_FIELDS_TYPES]): def add_documentation(self, d: str) -> None: self._documentation = d + def add_source(self, s: type) -> None: + self._source = s + def add_constraint(self, c: Constraint) -> None: self._constraints.append(c) @@ -83,32 +155,38 @@ class DABField(Generic[TV_ALLOWED_MODEL_FIELDS_TYPES]): self._value = v def render(self) -> TV_ALLOWED_MODEL_FIELDS_TYPES: - if isinstance(self._value, dict): - return MappingProxyType(self._value) - elif isinstance(self._value, set): - return frozenset(self._value) - return self._value + return _deepfreeze(self._value) + + def render_default(self) -> TV_ALLOWED_MODEL_FIELDS_TYPES: + return _deepfreeze(self._default_value) + + def get_annotation(self) -> Any: + return self._annotations class BaseMeta(type): def __new__(mcls, name, bases, namespace): - print("__NEW__ Defining:", name, "with keys:", list(namespace)) + # print("__NEW__ Defining:", name, "with keys:", list(namespace)) if len(bases) > 1: - raise RuntimeError("Multiple inheritance is not supported by dabmodel") + raise MultipleInheritanceForbidden("Multiple inheritance is not supported by dabmodel") elif len(bases) == 0: # base class (BaseElement) - ... + namespace["__DABSchema__"] = dict() else: # standard inheritance - for field in [_ for _ in dir(bases[0]) if isinstance(_, DABField)]: - print(field) + # check class tree origin + if "__DABSchema__" not in dir(bases[0]): + raise BrokenInheritance("__DABSchema__ not found in base class, broken inheritance chain.") + # copy inherited schema + namespace["__DABSchema__"] = copy(bases[0].__DABSchema__) # force field without default value to be instantiated (with None) if "__annotations__" in namespace: for _funknown in [_ for _ in namespace["__annotations__"] if _ not in namespace.keys()]: namespace[_funknown] = None - # iterating referenced / modified fields - new_values: Dict[str, Any] = {} + # iterating new and modified fields + modified_field: Dict[str, Any] = {} + new_fields: Dict[str, DABField] = {} for _fname, _fvalue in namespace.items(): if _fname.startswith("__"): pass @@ -116,63 +194,94 @@ class BaseMeta(type): ... # print("FOUND Constraints") else: - # print(f"Found random argument: {_fname} / {_fvalue}") - if len(bases) == 1 and _fname in dir(bases[0]): - # print(f"modified field: {_fname}") + # print(f"Parsing Field: {_fname} / {_fvalue}") + # Modified fields + if len(bases) == 1 and _fname in namespace["__DABSchema__"].keys(): + # print(f"Modified field: {_fname}") if _fname in namespace["__annotations__"]: - raise RuntimeError("types cannot be modified on derived classes") + raise ReadOnlyFieldAnnotation("annotations cannot be modified on derived classes") try: - check_type(_fvalue, bases[0].__annotations__[_fname]) - except TypeCheckError as exp: - raise TypeError( - f"Field <{_fname}> overriden value is not of expected type {bases[0].__annotations__[_fname]}.", exp + check_type( + _fvalue, + namespace["__DABSchema__"][_fname].get_annotation(), + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, ) - new_values[_fname] = _fvalue + except TypeCheckError as exp: + raise InvalidFieldValue( + f"Field <{_fname}> New Field value is not of expected type {bases[0].__annotations__[_fname]}." + ) from exp + modified_field[_fname] = _fvalue + # New fieds else: - print(f"new field: {_fname}") - print(f"type is: {type(_fvalue)}") + # print(f"New field: {_fname}") + # print(f"type is: {type(_fvalue)}") + # print(f"value is: {_fvalue}") # check if field is annotated - if _fname not in namespace["__annotations__"]: - raise RuntimeError(f"Every dabmodel Fields must be annotated ({_fname})") + if "__annotations__" not in namespace or _fname not in namespace["__annotations__"]: + raise NotAnnotatedField(f"Every dabmodel Fields must be annotated ({_fname})") # check if annotation is allowed - if not __check_type__(namespace["__annotations__"][_fname]): - raise TypeError(f"Field <{_fname}> has not an allowed or valid annotation.") + if isinstance(namespace["__annotations__"][_fname], str): + namespace["__annotations__"][_fname] = _resolve_annotation(namespace["__annotations__"][_fname]) + if not __check_annotation_definition__(namespace["__annotations__"][_fname]): + raise InvalidFieldAnnotation(f"Field <{_fname}> has not an allowed or valid annotation.") + + # print(f"annotation is: {namespace['__annotations__'][_fname]}") # check if value is valid try: - check_type(_fvalue, namespace["__annotations__"][_fname]) + check_type( + _fvalue, namespace["__annotations__"][_fname], collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS + ) except TypeCheckError as exp: - raise TypeError(f"Field <{_fname}> is not of expected type {namespace['__annotations__'][_fname]}.", exp) - namespace[_fname] = DABField(_fname, _fvalue, namespace["__annotations__"][_fname]) - # print(_fvalue.__doc__) + raise InvalidFieldValue( + f"Value of Field <{_fname}> is not of expected type {namespace['__annotations__'][_fname]}." + ) from exp + new_fields[_fname] = DABField(_fname, _fvalue, namespace["__annotations__"][_fname]) # namespace[_fname].add_documentation() - # removing modified fields from definition (will add them back later) - # __slots__ = [] - for _fname, _fvalue in new_values.items(): - namespace[_fname] = deepcopy(bases[0].__dict__[_fname]) - # __slots__.append(_fname) - # setattr(cls, "__slots__", tuple(__slots__)) - # namespace["__slots__"] = tuple(__slots__) - # namespace["__slots__"] = tuple() + # removing modified fields from class (will add them back later) + for _fname in new_fields.keys(): + del namespace[_fname] + for _fname in modified_field.keys(): + del namespace[_fname] + + orig_setattr = namespace.get("__setattr__", object.__setattr__) + + def guarded_setattr(self, key, value): + if key.startswith("_"): # allow private and dunder attrs + return orig_setattr(self, key, value) + # block writes after init if key is readonly + if key in self.__DABSchema__.keys(): + if hasattr(self, key): + raise ReadOnlyField(f"{key} is read-only") + else: + raise NewFieldForbidden(f"creating new fields is not allowed") + + return orig_setattr(self, key, value) + + namespace["__setattr__"] = guarded_setattr + cls = super().__new__(mcls, name, bases, namespace) - # if "DABSchema" + for _fname, _fvalue in modified_field.items(): + cls.__DABSchema__[_fname] = deepcopy(bases[0].__DABSchema__[_fname]) + cls.__DABSchema__[_fname].update_value(_fvalue) + + for _fname, _fvalue in new_fields.items(): + _fvalue.add_source(cls) + cls.__DABSchema__[_fname] = _fvalue - for fname, fvalue in new_values.items(): - cls.__dict__[fname].update_value(fvalue) - # cls.__DABModelSchema__ = DABModelSchema() return cls def __call__(cls, *args, **kw): obj = super().__call__(*args, **kw) - for fname in dir(cls): - if not fname.startswith("__"): - attr = getattr(cls, fname) - if isinstance(attr, DABField): - setattr(obj, fname, attr.render()) + + for _fname in cls.__DABSchema__.keys(): + setattr(obj, _fname, cls.__DABSchema__[_fname].render()) + # obj.__DABSchema__ = deepfreeze(obj.__DABSchema__) + # setattr(obj, "__DABSchema__", deepfreeze(obj.__DABSchema__)) return obj diff --git a/test/test_model.py b/test/test_model.py index 697d575..6963942 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -7,9 +7,13 @@ # work. If not, see . import unittest -from os import chdir + +import sys +import subprocess +from os import chdir, environ from pathlib import Path -from typing import List, Optional, Dict, Union, Tuple, Set, FrozenSet, TypeVar, Generic +import textwrap +from typing import List, Optional, Dict, Union, Tuple, Set, FrozenSet, TypeVar, Generic, Any, Annotated from pprint import pprint print(__name__) @@ -22,56 +26,506 @@ testdir_path = Path(__file__).parent.resolve() chdir(testdir_path.parent.resolve()) -# ---------- Base models to reuse in many tests ---------- - - -def create(): - - class Appliance1(dm.BaseAppliance): - MyStrVar: str = "default value" - MyStrVar2: str = "default value2" - - class Constraints: - MyStrVar = None - - class Appliance2(Appliance1): - MyStrVar = "modified value" # modified default value - MyStrVar3: Optional[str] - """ MyStrVar3 docstring""" - - MyDictVar1: Optional[Dict[str, str]] - MyDictVar2: Dict[str, Optional[str]] = {"a": "aa"} - MyDictVar3: Dict[str, Union[None | str]] = {} - MyListVar1: List[int] = [] - MyTupleVar1: Tuple[int, float] = (14, 12.3) - MyTupleVar2: Optional[Tuple[int, str]] - MySetVar1: Set[int] = {1, 7} - MySetVar2: Optional[Set[int]] - MyFrozenSetVar1: FrozenSet[int] = frozenset({1, 6}) - MyFrozenSetVar2: Optional[FrozenSet[int]] - # MyStrVar2 = 18 - - T_TestCustomType = Dict[str, str] - - class Appliance3(Appliance2): - MyStrVar10: Optional[str] = "coucou" - TestCustomType: T_TestCustomType = {} - - app = Appliance3() - print(dir(app)) - print(vars(app)) - print(app.MyStrVar3) - app.totota = 14 - print(app.MyDictVar2) - # app.MyDictVar2["a"] = 12 - print(app.MyFrozenSetVar1) - print(app.MySetVar1) - print("done") - - class TestConfigWithoutEnabledFlag(unittest.TestCase): - def test_1(self): - create() + 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.__DABSchema__.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_immutable_fields(self): + """Testing first appliance level, and Field types (simple)""" + + # class can be created + class Appliance1(dm.BaseAppliance): + StrVar: str = "default value" + StrVar2: str = "default value2" + VarInt: int = 12 + VarInt2: int = 21 + VarFloat: float = 12.1 + VarFloat2: float = 21.2 + VarComplex: complex = complex(3, 5) + VarComplex2: complex = complex(8, 6) + VarBool: bool = True + VarBool2: bool = False + VarBytes: bytes = bytes.fromhex("2Ef0 F1f2 ") + VarBytes2: bytes = bytes.fromhex("2ff0 F7f2 ") + + app1 = Appliance1() + + self.immutable_vars__test_field(app1, "StrVar", "default value", "test") + self.immutable_vars__test_field(app1, "StrVar2", "default value2", "test2") + self.immutable_vars__test_field(app1, "VarInt", 12, 13) + self.immutable_vars__test_field(app1, "VarInt2", 21, 22) + self.immutable_vars__test_field(app1, "VarFloat", 12.1, 32) + self.immutable_vars__test_field(app1, "VarFloat2", 21.2, 42) + self.immutable_vars__test_field(app1, "VarComplex", complex(3, 5), complex(1, 2)) + self.immutable_vars__test_field(app1, "VarComplex2", complex(8, 6), complex(3, 2)) + self.immutable_vars__test_field(app1, "VarBool", True, False) + self.immutable_vars__test_field(app1, "VarBool2", False, True) + self.immutable_vars__test_field(app1, "VarBytes", bytes.fromhex("2Ef0 F1f2 "), bytes.fromhex("11f0 F1f2 ")) + self.immutable_vars__test_field(app1, "VarBytes2", bytes.fromhex("2ff0 F7f2 "), bytes.fromhex("11f0 F1e2 ")) + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: str = 12 + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: int = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: float = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: complex = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: bool = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: bytes = "value" + + def test_annotation(self): + """Testing first appliance level, and Field types (simple annotations)""" + + # class can be created if annotation is a string + class Appliance1(dm.BaseAppliance): + StrVar: "str" = "default value" + StrVar2: "str" = "default value2" + VarInt: "int" = 12 + VarInt2: "int" = 21 + VarFloat: "float" = 12.1 + VarFloat2: "float" = 21.2 + VarComplex: "complex" = complex(3, 5) + VarComplex2: "complex" = complex(8, 6) + VarBool: "bool" = True + VarBool2: "bool" = False + VarBytes: "bytes" = bytes.fromhex("2Ef0 F1f2 ") + VarBytes2: "bytes" = bytes.fromhex("2ff0 F7f2 ") + + app1 = Appliance1() + + self.immutable_vars__test_field(app1, "StrVar", "default value", "test") + self.immutable_vars__test_field(app1, "StrVar2", "default value2", "test2") + self.immutable_vars__test_field(app1, "VarInt", 12, 13) + self.immutable_vars__test_field(app1, "VarInt2", 21, 22) + self.immutable_vars__test_field(app1, "VarFloat", 12.1, 32) + self.immutable_vars__test_field(app1, "VarFloat2", 21.2, 42) + self.immutable_vars__test_field(app1, "VarComplex", complex(3, 5), complex(1, 2)) + self.immutable_vars__test_field(app1, "VarComplex2", complex(8, 6), complex(3, 2)) + self.immutable_vars__test_field(app1, "VarBool", True, False) + self.immutable_vars__test_field(app1, "VarBool2", False, True) + self.immutable_vars__test_field(app1, "VarBytes", bytes.fromhex("2Ef0 F1f2 "), bytes.fromhex("11f0 F1f2 ")) + self.immutable_vars__test_field(app1, "VarBytes2", bytes.fromhex("2ff0 F7f2 "), bytes.fromhex("11f0 F1e2 ")) + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "str" = 12 + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "int" = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "float" = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "complex" = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "bool" = "value" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "bytes" = "value" + + # class cannot be created if not annotated field + with self.assertRaises(dm.NotAnnotatedField): + + class _(dm.BaseAppliance): + _ = "default value" + + def test_annotated(self): + """Testing first appliance level, and Field types (annotated one)""" + + # class can be created if annotation is a string + class Appliance1(dm.BaseAppliance): + StrVar: Annotated[str, "my string"] = "default value" + + def test_optionnal(self): + """Testing first appliance level, and Field types (Optionnal annotations)""" + + # class can be created with Optionnal (and variant) + class Appliance1(dm.BaseAppliance): + StrVar: Optional[str] = "default value" + StrVar2: Optional[str] + StrVar3: Union[None | str] = "default value" + StrVar4: Union[None | str] + StrVar5: None | str = "default value" + StrVar6: None | str + + app1 = Appliance1() + + self.immutable_vars__test_field(app1, "StrVar", "default value", "123") + self.immutable_vars__test_field(app1, "StrVar2", None, "123") + self.immutable_vars__test_field(app1, "StrVar3", "default value", "123") + self.immutable_vars__test_field(app1, "StrVar4", None, "123") + self.immutable_vars__test_field(app1, "StrVar5", "default value", "123") + self.immutable_vars__test_field(app1, "StrVar6", None, "123") + + # class can be created with Optionnal (and variant), as string annotation + class Appliance2(dm.BaseAppliance): + StrVar: "Optional[str]" = "default value" + StrVar2: "Optional[str]" + StrVar3: "Union[None | str]" = "default value" + StrVar4: "Union[None | str]" + StrVar5: "None | str" = "default value" + StrVar6: "None | str" + + app2 = Appliance2() + + self.immutable_vars__test_field(app2, "StrVar", "default value", "123") + self.immutable_vars__test_field(app2, "StrVar2", None, "123") + self.immutable_vars__test_field(app2, "StrVar3", "default value", "123") + self.immutable_vars__test_field(app2, "StrVar4", None, "123") + self.immutable_vars__test_field(app2, "StrVar5", "default value", "123") + self.immutable_vars__test_field(app2, "StrVar6", None, "123") + + @unittest.skip + def test_containers__set(self): + """Testing first appliance level, and Field types (Set)""" + + # class can be created with set + class Appliance1(dm.BaseAppliance): + testVar: Set[int] = {1, 2} + testVar2: Set[str] = {"a", "b"} + testVar3: Set[float] = {0.5, 0.456, 12} + testVar4: "Set[str]" = {"a", "c"} + testVar5: set[str] = {"a", "b"} + testVar6: "set[str]" = {"a", "b"} + testVar7: set[int | str] = {1, 2, "abcd", "efg"} + + app1 = Appliance1() + self.immutable_vars__test_field(app1, "testVar", {1, 2}, {1, 5}) + self.assertEqual(app1.testVar, {2, 1}) + self.immutable_vars__test_field(app1, "testVar2", {"a", "b"}, {"h", "c"}) + self.assertEqual(app1.testVar2, {"b", "a"}) + self.immutable_vars__test_field(app1, "testVar3", {0.5, 0.456, 12}, {0.9, 0.4156, 11}) + self.assertEqual(app1.testVar3, {0.456, 0.5, 12}) + self.immutable_vars__test_field(app1, "testVar4", {"a", "c"}, {"h", "e"}) + self.immutable_vars__test_field(app1, "testVar5", {"a", "b"}, {"h", "c"}) + self.immutable_vars__test_field(app1, "testVar6", {"a", "b"}, {"h", "c"}) + self.immutable_vars__test_field(app1, "testVar7", {1, 2, "abcd", "efg"}, {"h", "c"}) + + # must work + sorted(app1.testVar) + + with self.assertRaises(AttributeError): + app1.testVar.add(3) + + with self.assertRaises(AttributeError): + app1.testVar4.add("coucou") + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: Set[int] = {"a"} + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "Set[int]" = {"a"} + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: Set = {1, 2} + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: "Set" = {1, 2} + + # Hacky part ! + # set() are randomly ordered, and typeguard can be configured to only check 1st element of containers. + # we need to make sure it is properly configured, so we are expecting it to detect wrong type at any element + # this is why we need to run the code multiple time with a new interpreter that will use a different random seed + code = textwrap.dedent( + r""" + from src import dabmodel as dm + from typing import Set + try: + class Yours(dm.BaseAppliance): + My1: Set[int] = {99, 1, 3, "a", 5,6} + except dm.InvalidFieldValue as ex: + raise SystemExit(2) + raise SystemExit(0) + + """ + ) + + for i in range(15): + env = environ.copy() + env["PYTHONHASHSEED"] = str(i) + res = subprocess.run([sys.executable, "-c", code], env=env) + self.assertEqual(res.returncode, 2) + + @unittest.skip + def test_containers__frozenset(self): + """Testing first appliance level, and Field types (FrozenSet)""" + + # class can be created with set + class Appliance1(dm.BaseAppliance): + testVar: frozenset[int] = frozenset({1, 2}) + testVar2: frozenset[str] = frozenset({"a", "b"}) + testVar3: frozenset[float] = frozenset({0.5, 0.456, 12}) + testVar4: "frozenset[str]" = frozenset({"a", "c"}) + testVar5: FrozenSet[int] = frozenset({1, 2}) + testVar6: "FrozenSet[int]" = frozenset({1, 2}) + testVar7: FrozenSet[int | str] = frozenset({1, 2, "abcd", "efg"}) + + app1 = Appliance1() + self.immutable_vars__test_field(app1, "testVar", {1, 2}, {1, 5}) + self.assertEqual(app1.testVar, {2, 1}) + self.immutable_vars__test_field(app1, "testVar2", {"a", "b"}, {"h", "c"}) + self.assertEqual(app1.testVar2, {"b", "a"}) + self.immutable_vars__test_field(app1, "testVar3", {0.5, 0.456, 12}, {0.9, 0.4156, 11}) + self.assertEqual(app1.testVar3, {0.456, 0.5, 12}) + self.immutable_vars__test_field(app1, "testVar4", {"a", "c"}, {"h", "e"}) + self.immutable_vars__test_field(app1, "testVar5", {1, 2}, {1, 5}) + self.immutable_vars__test_field(app1, "testVar6", {1, 2}, {1, 5}) + self.immutable_vars__test_field(app1, "testVar7", {1, 2, "abcd", "efg"}, {1, 5}) + + # must work + sorted(app1.testVar) + + with self.assertRaises(AttributeError): + app1.testVar.add(3) + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: FrozenSet[int] = {"a"} + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "FrozenSet[int]" = {"a"} + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: FrozenSet = {1, 2} + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: "FrozenSet" = {1, 2} + + # Hacky part ! + # set() are randomly ordered, and typeguard can be configured to only check 1st element of containers. + # we need to make sure it is properly configured, so we are expecting it to detect wrong type at any element + # this is why we need to run the code multiple time with a new interpreter that will use a different random seed + code = textwrap.dedent( + r""" + from src import dabmodel as dm + from typing import FrozenSet + try: + class Yours(dm.BaseAppliance): + My1: FrozenSet[int] = frozenset({99, 1, 3, "a", 5,6}) + except dm.InvalidFieldValue as ex: + raise SystemExit(2) + raise SystemExit(0) + + """ + ) + + for i in range(15): + env = environ.copy() + env["PYTHONHASHSEED"] = str(i) + res = subprocess.run([sys.executable, "-c", code], env=env) + self.assertEqual(res.returncode, 2) + + def test_containers__list(self): + """Testing first appliance level, and Field types (List)""" + + # class can be created with list + class Appliance1(dm.BaseAppliance): + testVar: List[int] = [1, 2] + testVar2: List[str] = ["a", "b"] + testVar3: List[float] = [0.5, 0.456, 12] + testVar4: "List[str]" = ["a", "c"] + testVar5: list[str] = ["a", "b"] + testVar6: "list[str]" = ["a", "b"] + testVar7: List[Union[int, str]] = [1, 2, 3, "one", "two", "three"] + + app1 = Appliance1() + # Note: lists are converted to tuples + self.immutable_vars__test_field(app1, "testVar", (1, 2), [1, 5]) + self.immutable_vars__test_field(app1, "testVar2", ("a", "b"), ["h", "c"]) + self.immutable_vars__test_field(app1, "testVar3", (0.5, 0.456, 12), [0.9, 0.4156, 11]) + self.immutable_vars__test_field(app1, "testVar4", ("a", "c"), ["h", "e"]) + self.immutable_vars__test_field(app1, "testVar5", ("a", "b"), ["h", "c"]) + self.immutable_vars__test_field(app1, "testVar6", ("a", "b"), ["h", "c"]) + self.immutable_vars__test_field(app1, "testVar7", (1, 2, 3, "one", "two", "three"), ["h", "c"]) + + # must work + sorted(app1.testVar) + + with self.assertRaises(AttributeError): + app1.testVar.append(3) + + with self.assertRaises(AttributeError): + app1.testVar.pop() + + with self.assertRaises(AttributeError): + app1.testVar.sort() + + with self.assertRaises(AttributeError): + app1.testVar4.append("coucou") + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: List[int] = ["a"] + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "List[int]" = ["a"] + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: List = [1, 2] + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: "List" = [1, 2] + + def test_containers__dict(self): + """Testing first appliance level, and Field types (Dict)""" + + # class can be created with dict + class Appliance1(dm.BaseAppliance): + testVar: Dict[int, str] = {1: "a", 2: "b"} + testVar2: "Dict[int, str]" = {1: "c", 99: "d"} + + app1 = Appliance1() + self.immutable_vars__test_field(app1, "testVar", {1: "a", 2: "b"}, {1: "", 99: "i"}) + self.immutable_vars__test_field(app1, "testVar2", {1: "c", 99: "d"}, {10: "", 50: "i"}) + + # TODO: wrap exception type + with self.assertRaises(TypeError): + app1.testVar[58] = "aaa" + + # TODO: wrap exception type + with self.assertRaises(TypeError): + app1.testVar[1] = "ggg" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: Dict[int, str] = {1: 64, 2: "b"} + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "Dict[int, str]" = {1: 64, 2: "b"} + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: Dict = {1: 64, 2: "b"} + + # annotation is parsed before the library can do anything, so the exception can only be TypeError + with self.assertRaises(TypeError): + + class _(dm.BaseAppliance): + _: Dict[int] = {1: 64, 2: "b"} + + # annotation is parsed before the library can do anything, so the exception can only be TypeError + with self.assertRaises(TypeError): + + class _(dm.BaseAppliance): + _: "Dict[int]" = {1: 64, 2: "b"} + + def test_containers__tuple(self): + """Testing first appliance level, and Field types (Tuple)""" + + # class can be created with list + class Appliance1(dm.BaseAppliance): + testVar: Tuple[int, ...] = (1, 2) + testVar2: Tuple[str, ...] = ("a", "b") + testVar3: Tuple[float, ...] = (0.5, 0.456, 12) + testVar4: "Tuple[str,...]" = ("a", "c") + testVar5: tuple[str, ...] = ("a", "b") + testVar6: "tuple[str,...]" = ("a", "b") + # testVar7: Tuple[Union[int, str]] = (1, 2, 3, "one", "two", "three") + + app1 = Appliance1() + + self.immutable_vars__test_field(app1, "testVar", (1, 2), (1, 5)) + self.immutable_vars__test_field(app1, "testVar2", ("a", "b"), ("h", "c")) + self.immutable_vars__test_field(app1, "testVar3", (0.5, 0.456, 12), (0.9, 0.4156, 11)) + self.immutable_vars__test_field(app1, "testVar4", ("a", "c"), ("h", "e")) + self.immutable_vars__test_field(app1, "testVar5", ("a", "b"), ("h", "c")) + self.immutable_vars__test_field(app1, "testVar6", ("a", "b"), ("h", "c")) + # self.immutable_vars__test_field(app1, "testVar7", (1, 2, 3, "one", "two", "three"), ("h", "c")) + + # must work + sorted(app1.testVar) + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: Tuple[int] = "a" + + with self.assertRaises(dm.InvalidFieldValue): + + class _(dm.BaseAppliance): + _: "Tuple[int]" = "a" + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: Tuple = (1, 2) + + with self.assertRaises(dm.IncompletelyAnnotatedField): + + class _(dm.BaseAppliance): + _: "Tuple" = (1, 2) # ---------- main ----------