Compare commits

..

7 Commits

Author SHA1 Message Date
chacha
0eef35e36f continue work 2024-12-08 01:04:27 +01:00
cclecle
4c17436cea work 2024-12-07 21:57:12 +01:00
cclecle
a90ab4885b implement 2024-10-24 20:57:25 +01:00
cclecle
192dcc74f8 ok serialize 2024-10-21 23:46:07 +01:00
cclecle
171a2f1617 improve 2024-10-21 21:22:03 +01:00
cclecle
001ffbbbf1 good progress 2024-10-21 10:28:06 +01:00
cclecle
8ab6c8e179 first comit 2024-10-20 12:25:41 +01:00
8 changed files with 473 additions and 82 deletions

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>{{project_name}}</name>
<name>dabmodel</name>
<comment></comment>
<projects>
</projects>

View File

@@ -34,7 +34,9 @@ classifiers = [
]
dependencies = [
'importlib-metadata; python_version<"3.9"',
'packaging'
'packaging',
'pydantic',
'runtype'
]
dynamic = ["version"]
@@ -78,7 +80,7 @@ test = ["chacha_cicd_helper"]
coverage-check = ["chacha_cicd_helper"]
complexity-check = ["chacha_cicd_helper"]
quality-check = ["chacha_cicd_helper"]
type-check = ["chacha_cicd_helper"]
type-check = ["chacha_cicd_helper","types-pytz"]
doc-gen = ["chacha_cicd_helper"]
# [project.scripts]

View File

@@ -11,4 +11,4 @@ Main module __init__ file.
"""
from .__metadata__ import __version__, __Summuary__, __Name__
from .test_module import test_function
from .model import DABField, BaseFeature, BaseAppliance, default_values_override

198
src/dabmodel/model.py Normal file
View File

@@ -0,0 +1,198 @@
# pylint: disable=C0114,C0115,C0116
from __future__ import annotations
from abc import ABC, ABCMeta
from uuid import uuid4
from typing import Annotated, ClassVar, Any, Self, TypeVar, TypeAlias, Generic
from datetime import datetime
from copy import deepcopy, copy
from typing_extensions import dataclass_transform
from pydantic import (
BaseModel,
StrictInt,
StrictStr,
constr,
ByteSize,
AwareDatetime,
UUID4,
model_validator,
)
from pydantic.fields import Field, _Unset, PydanticUndefined
from pydantic._internal._model_construction import ModelMetaclass, PydanticModelField
from pydantic._internal._generics import PydanticGenericMetadata
from pydantic._internal._decorators import ensure_classmethod_based_on_signature
import pytz
from runtype import issubclass as runtype_issubclass
class NoInstanceMethod:
"""Descriptor to forbid that other descriptors can be looked up on an instance"""
def __init__(self, descr, name=None):
self.descr = descr
self.name = name
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
# enforce the instance cannot look up the attribute at all
if instance is not None:
raise AttributeError(f"{type(instance).__name__!r} has no attribute {self.name!r}")
# invoke any descriptor we are wrapping
return self.descr.__get__(instance, owner)
def DABField(default: Any = PydanticUndefined, *, final: bool | None = _Unset, finalize_only: bool | None = _Unset, **kwargs):
kwargs["final"] = final is not None
kwargs["finalize_only"] = finalize_only is not None
return Field(default, **kwargs)
T_BaseElement = TypeVar("T_BaseElement", bound="BaseElement")
T_BaseElement_ConfigMethod_Arg: TypeAlias = dict[str, Any]
T_BaseElement_ConfigMethod: TypeAlias = "classmethod[T_BaseElement, [T_BaseElement_ConfigMethod_Arg], T_BaseElement_ConfigMethod_Arg]"
class ConfigElement:
def __init__(self) -> None:
self.default_values_override_methods: dict[T_BaseElement_ConfigMethod, None] = {}
self.main_build_method: dict[T_BaseElement_ConfigMethod, None] = {}
def __copy__(self) -> Self:
# we cannot deepcopy because of classmethods, so we do a manual enhanced copy
cls = self.__class__
result = cls.__new__(cls)
for k, v in self.__dict__.items():
setattr(result, k, copy(v))
return result
class IBaseElement(BaseModel, ABC):
_config_element: ClassVar[ConfigElement] = ConfigElement()
@dataclass_transform(kw_only_default=True, field_specifiers=(PydanticModelField,))
class BaseElementMeta(ModelMetaclass, ABCMeta):
def __new__(
mcs,
cls_name: str,
bases: tuple[type[Any], ...],
namespace: dict[str, Any],
__pydantic_generic_metadata__: PydanticGenericMetadata | None = None,
__pydantic_reset_parent_namespace__: bool = True,
_create_model_module: str | None = None,
**kwargs: Any,
) -> type:
result = super().__new__(
mcs,
cls_name,
bases,
namespace,
None, #__pydantic_generic_metadata__,
True, #__pydantic_reset_parent_namespace__,
None, #_create_model_module,
**kwargs,
)
assert issubclass(result, IBaseElement), "Only IBaseElement subclasses are supported"
# forcing all Fields to be frozen
for _, field_val in result.model_fields.items():
field_val.frozen = True
# copying/forwarding base classes default-configs
if "_config_element" not in result.__dict__:
assert result.__base__ is not None, "Only IBaseElement subclasses are supported"
if issubclass(result.__base__, IBaseElement):
result._config_element = copy(result.__base__._config_element)
else:
result._config_element = ConfigElement()
# searching and storing current class default-configs
for _, method in result.__dict__.items():
if isinstance(method, classmethod):
if hasattr(method, "default_values_override"):
result._config_element.default_values_override_methods[method] = None
# todo: find a way to 'lock' and add restriction to a field after inheritance
return result
class BaseElement(
IBaseElement,
ABC,
validate_assignment=True,
# revalidate_instances="subclass-instances", # pydantic issue #10681
validate_default=True,
extra="forbid",
metaclass=BaseElementMeta,
):
class Config:
ignored_types = (NoInstanceMethod,)
template_id: Annotated[UUID4, DABField(..., repr=True)]
template_short_name: Annotated[
StrictStr, constr(strip_whitespace=True, to_lower=True, strict=True, max_length=16), DABField(..., repr=True)
]
template_long_name: Annotated[StrictStr | None, DABField()]
template_description: Annotated[StrictStr | None, DABField()]
_saved_default_value: ClassVar[dict[str, Any]]
@model_validator(mode="before")
@classmethod
def __default_values_override_hook__(cls, values: T_BaseElement_ConfigMethod_Arg) -> T_BaseElement_ConfigMethod_Arg:
# extracting default values that were set in model fields
cls._saved_default_value = {}
for field_key, field_val in cls.model_fields.items():
assert field_val.annotation is not None, "all fields must have annotation"
assert not runtype_issubclass(
field_val.annotation, BaseFeature
), "Features can only be in Appliance's features[] dict attribute"
if field_val.default != PydanticUndefined:
cls._saved_default_value[field_key] = deepcopy(field_val.default)
for method, _ in cls._config_element.default_values_override_methods.items():
method.__func__(cls, cls._saved_default_value)
cls._default_values_override_hook__input_apply__(values)
return cls._saved_default_value
@NoInstanceMethod
@classmethod
def _default_values_override_hook__input_apply__(
cls,
values: T_BaseElement_ConfigMethod_Arg,
):
# applying user-defined values
for attr_key, attr_val in values.items():
assert attr_key in cls.model_fields, f"given feature attribute does not exist ({attr_key})"
cls._saved_default_value[attr_key] = attr_val
class BaseFeature(BaseElement, ABC):
...
T_Feature = TypeVar("T_Feature", bound=BaseFeature)
class BaseAppliance(Generic[T_Feature], BaseElement, ABC):
cpu_cnt: Annotated[StrictInt, DABField(1, gt=0)]
ram_size: Annotated[ByteSize, DABField(256, gt=128)]
swap_size: Annotated[ByteSize, DABField(200, ge=0)]
rootfs_size: Annotated[ByteSize, DABField(2048, ge=2048)]
dabinst_id: Annotated[UUID4, DABField(uuid4(), repr=True)]
dabinst_short_name: Annotated[
StrictStr, constr(strip_whitespace=True, to_lower=True, strict=True, max_length=16), DABField(..., repr=True)
]
dabinst_long_name: Annotated[StrictStr | None, DABField("")]
dabinst_description: Annotated[StrictStr | None, DABField("")]
dabinst_creationdate: Annotated[AwareDatetime | None, DABField(datetime.now(tz=pytz.utc))]
def default_values_override(func: T_BaseElement_ConfigMethod) -> T_BaseElement_ConfigMethod:
func = ensure_classmethod_based_on_signature(func)
setattr(func, "default_values_override", lambda: True)
return func

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 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 <https://creativecommons.org/licenses/by-nc-sa/4.0/>.
"""Phasellus tellus lectus, volutpat eu dapibus ut, suscipit vel augue.
Tips:
Aliquam non leo vel libero sagittis viverra. Quisque lobortis nunc sit amet augue euismod laoreet.
Note:
Maecenas volutpat porttitor pretium. Aliquam suscipit quis nisi non imperdiet.
Note:
Vivamus et efficitur lorem, eget imperdiet tortor. Integer vel interdum sem.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING: # Only imports the below statements during type checking
pass
def test_function(testvar: int) -> int:
""" A test function that return testvar+1 and print "Hello world !"
Proin eget sapien eget ipsum efficitur mollis nec ac nibh.
Note:
Morbi id lectus maximus, condimentum nunc eget, porta felis. In tristique velit tortor.
Args:
testvar: any integer
Returns:
testvar+1
"""
print("Hello world !")
return testvar+1

37
test/test_debug.py Normal file
View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel, SerializeAsAny
class commonbase(
BaseModel,
revalidate_instances="subclass-instances", # toogle to generate error
): ...
class basechild(commonbase):
test_val: int = 1
class derivedchild(basechild):
test_val2: int = 2
class container(commonbase):
ct_child_1: dict[str, basechild] = {}
ct_child_2: SerializeAsAny[dict[str, basechild]] = {}
ct_child_3: dict[str, SerializeAsAny[basechild]] = {}
if __name__ == "__main__":
test_val = container(
ct_child_1={"test1": derivedchild()},
ct_child_2={"test2": derivedchild()},
ct_child_3={"test3": derivedchild()},
)
print(test_val.model_dump_json(indent=1))
print(test_val.model_dump())
assert "test_val2" not in test_val.model_dump()["ct_child_1"]["test1"]
assert "test_val2" in test_val.model_dump()["ct_child_2"]["test2"]
assert "test_val2" in test_val.model_dump()["ct_child_3"]["test3"]

232
test/test_model.py Normal file
View File

@@ -0,0 +1,232 @@
# 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 <https://creativecommons.org/licenses/by-nc-sa/4.0/>.
import unittest
from os import chdir
from pathlib import Path
from pydantic import StrictInt, model_validator
from pydantic.fields import Field
print(__name__)
print(__package__)
from src import dabmodel
from typing import Annotated, Any, Optional
from uuid import uuid4
import json
from uuid import UUID
from datetime import datetime
testdir_path = Path(__file__).parent.resolve()
chdir(testdir_path.parent.resolve())
class UUIDEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, UUID):
# if the obj is uuid, we simply return the value of uuid
return obj.hex
elif isinstance(obj, datetime):
return str(obj)
return json.JSONEncoder.default(self, obj)
class MyAppliance(dabmodel.BaseAppliance):
app_specifi_integer_arg: Annotated[StrictInt | None, dabmodel.DABField(42)]
class _MyFeature(dabmodel.BaseFeature):
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Feature 1")
values["template_id"] = "421d61cb-e364-46d8-9b77-ec439f1fa666"
values["template_short_name"] = "my-feature-1"
values["template_long_name"] = "My feature template 1 !!"
values["template_description"] = """A very nice FEature 1"""
MyFeature : Annotated[Optional[_MyFeature],dabmodel.DABField(_MyFeature())]
class _MyFeature2(dabmodel.BaseFeature):
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Feature 2")
values["template_id"] = "421d61cb-e364-46d8-9b77-ec439f1fa666"
values["template_short_name"] = "my-feature-2"
values["template_long_name"] = "My feature template 2 !!"
values["template_description"] = """A very nice FEature 2"""
MyFeature2 : Annotated[Optional[_MyFeature2],dabmodel.DABField(_MyFeature2())]
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Appliance 1")
print(f"!!!! {values['rootfs_size']}")
values["template_id"] = "421d61cb-e364-46d8-9b64-ec439f1faae8"
values["template_short_name"] = "my-app- tem 1"
values["template_long_name"] = "My appliance template 1 !!"
values["template_description"] = """A very nice Appliance 1"""
values["ram_size"] = 1024
#cls.add_feature(cls.MyFeature())
#cls.add_feature(cls.MyFeature2())
class MyAppliance2(MyAppliance):
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Appliance 2")
print(f"!!!! {values['template_id']}")
values["template_id"] = "421d61cb-e664-46d8-9b64-ec439f1fafff"
values["template_short_name"] = "my-app- tem 2"
values["template_long_name"] = "My appliance template 2 !!"
values["template_description"] = """A very nice Appliance 2"""
values["MyFeature"] = None
#cls.del_feature(MyAppliance.MyFeature)
# values["features"]["MyFeature2"].template_description = """Override feature desc"""
class MyAppliance3(dabmodel.BaseAppliance):
class _MyFeature6(MyAppliance._MyFeature):
# testtt: Annotated[MyAppliance.MyFeature, Field(MyAppliance.MyFeature())] # error case
test_integer: Annotated[int, dabmodel.DABField(200, ge=0)]
test_integer1234: Annotated[int, dabmodel.DABField(200, ge=0)]
#test_integer: int = dabmodel.DABField(200, ge=0)
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Feature 1 (modified)")
values["template_id"] = "421d61cb-e364-46d8-9b77-ec439f1fa778"
values["template_short_name"] = "my-feature-1-bis"
values["test_integer"] = 666
MyFeature6 : Annotated[Optional[_MyFeature6],dabmodel.DABField(_MyFeature6())]
class _MyFeature7(dabmodel.BaseFeature):
# testtt: Annotated[MyAppliance.MyFeature, Field(MyAppliance.MyFeature())] # error case
test_integer_2: Annotated[int, dabmodel.DABField(759, ge=0)]
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Feature 7")
values["template_id"] = "421d61cb-e364-46d8-ac55-ec439f1fa778"
values["template_short_name"] = "my-feature-7"
values["template_long_name"] = "My appliance template 7 !!"
values["template_description"] = """A very nice Appliance 7"""
values["test_integer_2"] = 3844
MyFeature7 : Annotated[Optional[_MyFeature7],dabmodel.DABField(_MyFeature7())]
# testtt: Annotated[MyAppliance.MyFeature, Field(MyAppliance.MyFeature())] # error case
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Appliance 3")
values["template_id"] = "421d61cb-e364-46d8-9b64-ec439f1faaaa"
values["template_short_name"] = "my-app- tem 3"
values["template_long_name"] = "My appliance template 3 !!"
values["template_description"] = """A very nice Appliance 3"""
values["ram_size"] = 3076
print("CREATE FEATURE")
#cls.add_feature(cls.MyFeature6())
#cls.add_feature(cls.MyFeature7())
print("!!! CONFIG Appliance 3 DONE")
class MyAppliance4(MyAppliance):
class _MyFeature8(dabmodel.BaseFeature):
# testtt: Annotated[MyAppliance.MyFeature, Field(MyAppliance.MyFeature())] # error case (nested feature)
# test_integer_10: Annotated[int, dabmodel.DABField(3189, ge=0, toto="tata")] # error case (extra field)
test_integer_10: Annotated[int, dabmodel.DABField(3189, ge=0, toto="tata")]
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Feature 8")
values["template_id"] = "421d61cb-e364-46d8-ac55-ec4398888778"
values["template_short_name"] = "my-feature-8"
values["template_long_name"] = "My appliance template 8 !!"
values["template_description"] = """A very nice Appliance 8"""
values["test_integer_10"] = 951753
# values["tete"] = 1 # error case (extra field in feature)
MyFeature8 : Annotated[Optional[_MyFeature8],dabmodel.DABField(_MyFeature8())]
# testtt: Annotated[MyAppliance.MyFeature, Field(MyAppliance.MyFeature())] # error case (feature not in features[] list)
@dabmodel.default_values_override
@classmethod
def __override_config__(cls, values):
print("!!! CONFIG Appliance 4")
values["template_id"] = "421d1234-e364-46d8-9b64-ec439f1faaaa"
values["template_short_name"] = "my-app-tem 4"
values["template_long_name"] = "My appliance template 4 !!"
values["template_description"] = """A very nice Appliance 4"""
values["ram_size"] = 954
print("CREATE FEATURE")
#cls.add_feature(cls.MyFeature8())
print("!!! CONFIG Appliance 4 DONE")
class TestModel(unittest.TestCase):
def setUp(self) -> None:
chdir(testdir_path.parent.resolve())
def test_version(self):
self.assertNotEqual(dabmodel.__version__, "?.?.?")
def test_model(self):
feature1 = MyAppliance._MyFeature()
print(feature1)
print(MyAppliance._MyFeature)
print(MyAppliance._MyFeature.__name__)
print(MyAppliance._MyFeature.__class__)
print("==")
print(feature1.model_dump_json(indent=1))
app = MyAppliance(dabinst_short_name="my-app-1", app_specifi_integer_arg=123)
app2 = MyAppliance2(dabinst_short_name="my-app-2", app_specifi_integer_arg=654)
app3 = MyAppliance3(dabinst_short_name="my-app-3", template_description="FORCED")
print(app.model_dump_json(indent=1))
print(app2.model_dump_json(indent=1))
print(app3.model_dump_json(indent=1))
app3 = MyAppliance3(dabinst_short_name="my-app-3", template_description="FORCED")
tmp_json = app3.dict()
tmp_json["MyFeature7"]["test_integer_2"] = 123
print(tmp_json)
recreated_obj = MyAppliance3.model_validate_json(json.dumps(tmp_json, cls=UUIDEncoder))
print(recreated_obj)
print(recreated_obj.model_dump_json(indent=1))
app4 = MyAppliance4(dabinst_short_name="my-app-4", template_description="FORCED2")
tmp_json = app4.dict()
tmp_json["MyFeature"]["template_description"] = "blablabla"
tmp_json["MyFeature2"]["template_description"] = "blablabla2"
print(tmp_json)
recreated_obj = MyAppliance4.model_validate_json(json.dumps(tmp_json, cls=UUIDEncoder))
print(recreated_obj)
print(recreated_obj.model_dump_json(indent=1))
# tmp_json["non-existing"] = "test" # error case
# tmp_json["non-existing"] = "test" # error case
# tmp_json["MyFeature"]["132"] = "test" # error case
recreated_obj = MyAppliance4.model_validate_json(json.dumps(tmp_json, cls=UUIDEncoder))
# app3.add_feature(MyAppliance.MyFeature()) # error case (add_feature not callable from instance)
for name in globals().keys():
print(name)

View File

@@ -1,35 +0,0 @@
# 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 <https://creativecommons.org/licenses/by-nc-sa/4.0/>.
import unittest
from os import chdir
from io import StringIO
from contextlib import redirect_stdout,redirect_stderr
from pathlib import Path
print(__name__)
print(__package__)
from src import dabmodel
testdir_path = Path(__file__).parent.resolve()
chdir(testdir_path.parent.resolve())
class Testtest_module(unittest.TestCase):
def setUp(self) -> None:
chdir(testdir_path.parent.resolve())
def test_version(self):
self.assertNotEqual(dabmodel.__version__,"?.?.?")
def test_test_module(self):
with redirect_stdout(StringIO()) as capted_stdout, redirect_stderr(StringIO()) as capted_stderr:
self.assertEqual(dabmodel.test_function(41),42)
self.assertEqual(len(capted_stderr.getvalue()),0)
self.assertEqual(capted_stdout.getvalue().strip(),"Hello world !")