From ae9669061f6d31c0752629b4b64f7bf80573d465 Mon Sep 17 00:00:00 2001 From: Frederik Van der Veken Date: Sat, 4 Jan 2025 22:45:14 +0100 Subject: [PATCH 01/26] Created release branch release/v0.3.0rc0. --- pyproject.toml | 2 +- tests/test_version.py | 2 +- xaux/general.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7fc137b..5a84e07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xaux" -version = "0.2.2" +version = "0.3.0rc0" description = "Support tools for Xsuite packages" authors = ["Frederik F. Van der Veken ", "Thomas Pugnat ", diff --git a/tests/test_version.py b/tests/test_version.py index d7b848f..afad732 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -6,5 +6,5 @@ from xaux import __version__ def test_version(): - assert __version__ == '0.2.2' + assert __version__ == '0.3.0rc0' diff --git a/xaux/general.py b/xaux/general.py index 81e60e5..0167784 100644 --- a/xaux/general.py +++ b/xaux/general.py @@ -10,5 +10,5 @@ # =================== # Do not change # =================== -__version__ = '0.2.2' +__version__ = '0.3.0rc0' # =================== From 20f11dd7c7b7a6eed0a763a29d4a9f5df7a86b67 Mon Sep 17 00:00:00 2001 From: Frederik Van der Veken Date: Wed, 8 Jan 2025 19:53:55 +0100 Subject: [PATCH 02/26] Added singleton implementation --- xaux/__init__.py | 1 + xaux/class_tools.py | 74 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 xaux/class_tools.py diff --git a/xaux/__init__.py b/xaux/__init__.py index d372743..615d2d4 100644 --- a/xaux/__init__.py +++ b/xaux/__init__.py @@ -7,3 +7,4 @@ from .protectfile import ProtectFile, ProtectFileError, get_hash from .fs import FsPath, LocalPath, EosPath, AfsPath, afs_accessible, eos_accessible, is_egroup_member, cp, mv from .dev_tools import import_package_version # Stub +from .class_tools import singleton diff --git a/xaux/class_tools.py b/xaux/class_tools.py new file mode 100644 index 0000000..7a1bf8d --- /dev/null +++ b/xaux/class_tools.py @@ -0,0 +1,74 @@ +# copyright ############################### # +# This file is part of the Xcoll Package. # +# Copyright (c) CERN, 2024. # +# ######################################### # + +import functools +from pathlib import Path + + +def singleton(cls, allow_underscore_vars_in_init=False): + # Monkey-patch the __new__ method to create a singleton + original_new = cls.__new__ if '__new__' in cls.__dict__ else None + def singleton_new(cls, *args, **kwargs): + if not hasattr(cls, 'instance'): + cls.instance = (original_new(cls, *args, **kwargs) \ + if original_new \ + else super(cls, cls).__new__(cls)) + cls.instance._initialised = False + cls.instance._valid = True + return cls.instance + cls.__new__ = singleton_new + + # Monkey-patch the __init__ method to set the singleton fields + original_init = cls.__init__ if '__init__' in cls.__dict__ else None + def singleton_init(self, *args, **kwargs): + kwargs.pop('_initialised', None) + if not self._initialised: + if original_init: + original_init(self, *args, **kwargs) + else: + super(cls, self).__init__(*args, **kwargs) + self._initialised = True + for kk, vv in kwargs.items(): + if not allow_underscore_vars_in_init and kk.startswith('_'): + raise ValueError(f"Cannot set private attribute {kk} for {cls.__name__}!") + if not hasattr(self, kk) and not hasattr(cls, kk): + raise ValueError(f"Invalid attribute {kk} for {cls.__name__}!") + setattr(self, kk, vv) + cls.__init__ = singleton_init + + # Define the get_self method + @classmethod + def get_self(cls, **kwargs): + # Need to initialise class once to get the allowed fields on the instance + # TODO: this does not work if the __init__ has obligatory arguments + cls() + filtered_kwargs = {key: value for key, value in kwargs.items() + if hasattr(cls, key) or hasattr(cls.instance, key)} + if not allow_underscore_vars_in_init: + filtered_kwargs = {key: value for key, value in filtered_kwargs.items() + if not key.startswith('_')} + return cls(**filtered_kwargs) + cls.get_self = get_self + + # Define the delete method + @classmethod + def delete(cls): + if hasattr(cls, 'instance'): + cls.instance._valid = False # Invalidate existing instances + del cls.instance + cls.delete = delete + + # Monkey-patch the __getattribute__ method to assert the instance belongs to the current singleton + original_getattribute = cls.__getattribute__ if '__getattribute__' in cls.__dict__ else None + def singleton_getattribute(self, name): + if not super(cls, self).__getattribute__('_valid'): + raise RuntimeError(f"This instance of the singleton {cls.__name__} has been invalidated!") + if original_getattribute: + return original_getattribute(self, name) + else: + return super(cls, self).__getattribute__(name) + cls.__getattribute__ = singleton_getattribute + + return cls From 668fd1da68e371cfedd156efadfa35d9731fd033 Mon Sep 17 00:00:00 2001 From: Frederik Van der Veken Date: Wed, 8 Jan 2025 19:54:30 +0100 Subject: [PATCH 03/26] Added singleton tests --- tests/test_class_tools.py | 183 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/test_class_tools.py diff --git a/tests/test_class_tools.py b/tests/test_class_tools.py new file mode 100644 index 0000000..05bbdbc --- /dev/null +++ b/tests/test_class_tools.py @@ -0,0 +1,183 @@ +# copyright ############################### # +# This file is part of the Xaux Package. # +# Copyright (c) CERN, 2024. # +# ######################################### # + +import pytest +from xaux import singleton, ClassProperty, ClassPropertyMeta + +def test_singleton(): + # Non-singleton example. + class NonSingletonClass: + def __init__(self, value=3): + self.value = value + + instance1 = NonSingletonClass() + assert instance1.value == 3 + instance2 = NonSingletonClass(value=5) + assert instance1 is not instance2 + assert id(instance1) != id(instance2) + assert instance1.value == 3 + assert instance2.value == 5 + instance1.value = 7 + assert instance1.value == 7 + assert instance2.value == 5 + + @singleton + class SingletonClass: + def __init__(self, value=3): + self.value = value + + # Initialise with default value + assert not hasattr(SingletonClass, 'instance') + instance1 = SingletonClass() + assert hasattr(SingletonClass, 'instance') + assert instance1.value == 3 + + # Initialise with specific value + instance2 = SingletonClass(value=5) + assert instance1 is instance2 + assert id(instance1) == id(instance2) + assert instance1.value == 5 + assert instance2.value == 5 + + # Initialise with default value again - this should not change the value + instance3 = SingletonClass() + assert instance1.value == 5 + assert instance2.value == 5 + assert instance3.value == 5 + + # Change the value of the instance + instance1.value = 7 + assert instance1.value == 7 + assert instance2.value == 7 + assert instance3.value == 7 + + # Remove the singleton + SingletonClass.delete() + assert not hasattr(SingletonClass, 'instance') + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass " + + "has been invalidated!"): + instance1.value + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass " + + "has been invalidated!"): + instance2.value + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass " + + "has been invalidated!"): + instance3.value + + # First initialisation with specific value + instance4 = SingletonClass(value=8) + assert hasattr(SingletonClass, 'instance') + assert instance4.value == 8 + assert instance1 is not instance4 + assert instance2 is not instance4 + assert instance3 is not instance4 + assert id(instance1) != id(instance4) + assert id(instance2) != id(instance4) + assert id(instance3) != id(instance4) + + +def test_get_self(): + @singleton + class SingletonClass: + def __init__(self, value=3): + self.value = value + + # Initialise with default value + instance = SingletonClass() + instance.value = 10 + + # Get self with default value + self1 = SingletonClass.get_self() + assert self1 is instance + assert id(self1) == id(instance) + assert self1.value == 10 + assert instance.value == 10 + + # Get self with specific value + self2 = SingletonClass.get_self(value=11) + assert self2 is instance + assert self2 is self1 + assert id(self2) == id(instance) + assert id(self2) == id(self1) + assert instance.value == 11 + assert self1.value == 11 + assert self2.value == 11 + + # Get self with non-existing attribute + self3 = SingletonClass.get_self(non_existing_attribute=13) + assert self3 is instance + assert self3 is self1 + assert self3 is self2 + assert id(self3) == id(instance) + assert id(self3) == id(self1) + assert id(self3) == id(self2) + assert instance.value == 11 + assert self1.value == 11 + assert self2.value == 11 + assert self3.value == 11 + assert not hasattr(SingletonClass, 'non_existing_attribute') + assert not hasattr(instance, 'non_existing_attribute') + assert not hasattr(self1, 'non_existing_attribute') + assert not hasattr(self2, 'non_existing_attribute') + assert not hasattr(self3, 'non_existing_attribute') + + # Get self with specific value and non-existing attribute + self4 = SingletonClass.get_self(value=12, non_existing_attribute=13) + assert self4 is instance + assert self4 is self1 + assert self4 is self2 + assert self4 is self3 + assert id(self4) == id(instance) + assert id(self4) == id(self1) + assert id(self4) == id(self2) + assert id(self4) == id(self3) + assert instance.value == 12 + assert self1.value == 12 + assert self2.value == 12 + assert self3.value == 12 + assert self4.value == 12 + assert not hasattr(SingletonClass, 'non_existing_attribute') + assert not hasattr(instance, 'non_existing_attribute') + assert not hasattr(self1, 'non_existing_attribute') + assert not hasattr(self2, 'non_existing_attribute') + assert not hasattr(self3, 'non_existing_attribute') + assert not hasattr(self4, 'non_existing_attribute') + + # Remove the singleton + SingletonClass.delete() + + # Initialise with get self with default value + self5 = SingletonClass.get_self() + assert self5 is not instance + assert self5 is not self1 + assert self5 is not self2 + assert self5 is not self3 + assert self5 is not self4 + assert id(self5) != id(instance) + assert id(self5) != id(self1) + assert id(self5) != id(self2) + assert id(self5) != id(self3) + assert id(self5) != id(self4) + assert self5.value == 3 + + # Remove the singleton + SingletonClass.delete() + + # Initialise with get self with specific value + self6 = SingletonClass.get_self(value=-3) + assert self6 is not instance + assert self6 is not self1 + assert self6 is not self2 + assert self6 is not self3 + assert self6 is not self4 + assert self6 is not self5 + assert id(self6) != id(instance) + assert id(self6) != id(self1) + assert id(self6) != id(self2) + assert id(self6) != id(self3) + assert id(self6) != id(self4) + assert id(self6) != id(self5) + assert self6.value == -3 + From 19926953f9dde4ecc365169c3567f0871d6167b6 Mon Sep 17 00:00:00 2001 From: Frederik Van der Veken Date: Wed, 8 Jan 2025 19:55:17 +0100 Subject: [PATCH 04/26] Added ClassProperty --- xaux/class_tools.py | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/xaux/class_tools.py b/xaux/class_tools.py index 7a1bf8d..a4eb90d 100644 --- a/xaux/class_tools.py +++ b/xaux/class_tools.py @@ -72,3 +72,84 @@ def singleton_getattribute(self, name): cls.__getattribute__ = singleton_getattribute return cls + + +class ClassPropertyMeta(type): + def __setattr__(cls, key, value): + # Check if the attribute is a ClassProperty + for parent in cls.__mro__: + if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): + return parent.__dict__[key].__set__(cls, value) + return super(ClassPropertyMeta, cls).__setattr__(key, value) + + +class ClassProperty: + _registry = {} # Registry to store ClassProperty names for each class + + @classmethod + def get_properties(cls, owner, parents=True): + if not parents: + return cls._registry.get(owner, []) + else: + return [prop for parent in owner.__mro__ + for prop in cls._registry.get(parent, [])] + + def __init__(self, fget=None, fset=None, fdel=None, doc=None): + functools.update_wrapper(self, fget) + self.fget = fget + self.fset = fset + self.fdel = fdel + if doc is None and fget is not None: + doc = fget.__doc__ + self.__doc__ = doc + + def __set_name__(self, owner, name): + self.name = name + # Verify that the class is a subclass of ClassPropertyMeta + if ClassPropertyMeta not in type(owner).__mro__: + raise AttributeError(f"Class `{owner.__name__}` must be have ClassPropertyMeta " + + f"as a metaclass to be able to use ClassProperties!") + # Add the property name to the registry for the class + if owner not in ClassProperty._registry: + ClassProperty._registry[owner] = [] + ClassProperty._registry[owner].append(name) + # Create default getter, setter, and deleter + if self.fget is None: + def _getter(*args, **kwargs): + raise AttributeError(f"Unreadable attribute '{name}' of {owner.__name__} class!") + self.fget = _getter + if self.fset is None: + def _setter(self, *args, **kwargs): + raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no setter") + self.fset = _setter + if self.fdel is None: + def _deleter(*args, **kwargs): + raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no deleter") + self.fdel = _deleter + + def __get__(self, instance, owner): + if owner is None: + owner = type(instance) + try: + return self.fget(owner) + except ValueError: + # Return a fallback if initialisation fails + return None + + def __set__(self, cls, value): + self.fset(cls, value) + + def __delete__(self, instance): + self.fdel(instance.__class__) + + def getter(self, fget): + self.fget = fget + return self + + def setter(self, fset): + self.fset = fset + return self + + def deleter(self, fdel): + self.fdel = fdel + return self From 5139d89b09905ef9cbba70ac0bff1993bb4ead6a Mon Sep 17 00:00:00 2001 From: Frederik Van der Veken Date: Thu, 9 Jan 2025 01:42:40 +0100 Subject: [PATCH 05/26] Added easy tools to count arguments and used this to protect __init__ for singleton (no required arguments allowed --- xaux/__init__.py | 2 +- xaux/tools/__init__.py | 2 ++ xaux/{ => tools}/class_tools.py | 10 ++++-- xaux/tools/function_tools.py | 59 +++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) rename xaux/{ => tools}/class_tools.py (93%) create mode 100644 xaux/tools/function_tools.py diff --git a/xaux/__init__.py b/xaux/__init__.py index 615d2d4..cff8c3d 100644 --- a/xaux/__init__.py +++ b/xaux/__init__.py @@ -7,4 +7,4 @@ from .protectfile import ProtectFile, ProtectFileError, get_hash from .fs import FsPath, LocalPath, EosPath, AfsPath, afs_accessible, eos_accessible, is_egroup_member, cp, mv from .dev_tools import import_package_version # Stub -from .class_tools import singleton +from .tools import singleton, ClassProperty, ClassPropertyMeta diff --git a/xaux/tools/__init__.py b/xaux/tools/__init__.py index 526b06d..1c770f5 100644 --- a/xaux/tools/__init__.py +++ b/xaux/tools/__init__.py @@ -4,3 +4,5 @@ # ######################################### # from .general_tools import * +from .function_tools import * +from .class_tools import * diff --git a/xaux/class_tools.py b/xaux/tools/class_tools.py similarity index 93% rename from xaux/class_tools.py rename to xaux/tools/class_tools.py index a4eb90d..55d0ab4 100644 --- a/xaux/class_tools.py +++ b/xaux/tools/class_tools.py @@ -6,6 +6,8 @@ import functools from pathlib import Path +from .function_tools import count_required_arguments + def singleton(cls, allow_underscore_vars_in_init=False): # Monkey-patch the __new__ method to create a singleton @@ -22,6 +24,10 @@ def singleton_new(cls, *args, **kwargs): # Monkey-patch the __init__ method to set the singleton fields original_init = cls.__init__ if '__init__' in cls.__dict__ else None + if original_init: + if count_required_arguments(original_init) > 1: + raise ValueError(f"Cannot create a singleton with an __init__ method that " + + "has more than one required argument (only 'self' is allowed)!") def singleton_init(self, *args, **kwargs): kwargs.pop('_initialised', None) if not self._initialised: @@ -41,8 +47,8 @@ def singleton_init(self, *args, **kwargs): # Define the get_self method @classmethod def get_self(cls, **kwargs): - # Need to initialise class once to get the allowed fields on the instance - # TODO: this does not work if the __init__ has obligatory arguments + # Need to initialise class in case the instance does not yet exist + # (to recognise get the allowed fields) cls() filtered_kwargs = {key: value for key, value in kwargs.items() if hasattr(cls, key) or hasattr(cls.instance, key)} diff --git a/xaux/tools/function_tools.py b/xaux/tools/function_tools.py new file mode 100644 index 0000000..b402c5b --- /dev/null +++ b/xaux/tools/function_tools.py @@ -0,0 +1,59 @@ +# copyright ############################### # +# This file is part of the Xcoll Package. # +# Copyright (c) CERN, 2024. # +# ######################################### # + +import inspect + + +def count_arguments(func, count_variable_length_args=False): + i = 0 + sig = inspect.signature(func) + for param in sig.parameters.values(): + if param.kind == inspect.Parameter.POSITIONAL_ONLY \ + or param.kind == inspect.Parameter.KEYWORD_ONLY \ + or param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + i += 1 + if count_variable_length_args: + if param.kind == inspect.Parameter.VAR_POSITIONAL \ + or param.kind == inspect.Parameter.VAR_KEYWORD: + i += 1 + return i + +def count_required_arguments(func): + i = 0 + sig = inspect.signature(func) + for param in sig.parameters.values(): + if (param.kind == inspect.Parameter.POSITIONAL_ONLY \ + or param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD) \ + and param.default == inspect.Parameter.empty: + i += 1 + return i + +def count_optional_arguments(func): + i = 0 + sig = inspect.signature(func) + for param in sig.parameters.values(): + if (param.kind == inspect.Parameter.KEYWORD_ONLY \ + or param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD) \ + and param.default != inspect.Parameter.empty: + i += 1 + return i + +def has_variable_length_arguments(func): + return has_variable_length_positional_arguments(func) \ + or has_variable_length_keyword_arguments(func) + +def has_variable_length_positional_arguments(func): + sig = inspect.signature(func) + for param in sig.parameters.values(): + if param.kind == inspect.Parameter.VAR_POSITIONAL: + return True + return False + +def has_variable_length_keyword_arguments(func): + sig = inspect.signature(func) + for param in sig.parameters.values(): + if param.kind == inspect.Parameter.VAR_KEYWORD: + return True + return False From 02c3619fd30bd8ef9fd560c2827c23cefe0307f6 Mon Sep 17 00:00:00 2001 From: Frederik Van der Veken Date: Thu, 9 Jan 2025 01:43:08 +0100 Subject: [PATCH 06/26] Added test for function tools --- tests/test_function_tools.py | 210 +++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/test_function_tools.py diff --git a/tests/test_function_tools.py b/tests/test_function_tools.py new file mode 100644 index 0000000..68f52e3 --- /dev/null +++ b/tests/test_function_tools.py @@ -0,0 +1,210 @@ +# copyright ############################### # +# This file is part of the Xaux Package. # +# Copyright (c) CERN, 2024. # +# ######################################### # + +from xaux.tools import count_arguments, count_required_arguments, count_optional_arguments, \ + has_variable_length_arguments, has_variable_length_positional_arguments, \ + has_variable_length_keyword_arguments + + +def _func_test_1(): + pass +def _func_test_2(b): + pass +def _func_test_3(b, c): + pass +def _func_test_4(c=3): + pass +def _func_test_5(a, c=7): + pass +def _func_test_6(*args): + pass +def _func_test_7(a, b, c, *args): + pass +def _func_test_8(*args, c=3, d=6): + pass +def _func_test_9(a, b, *args, c=3, d=6): + pass +def _func_test_10(**kwargs): + pass +def _func_test_11(a, b, **kwargs): + pass +def _func_test_12(*args, **kwargs): + pass +def _func_test_13(a=43, **kwargs): + pass +def _func_test_14(a, *args, **kwargs): + pass +def _func_test_15(a, b, c=-93, **kwargs): + pass +def _func_test_16(*args, a=23, b=-78, **kwargs): + pass +def _func_test_17(a, b, c, *args, d=3, e=89, f=897, g=-0.8, **kwargs): + pass +def _func_test_18(a, /): + pass +def _func_test_19(a, /, b=4.31): + pass +def _func_test_20(a, /, b, c, *args, d=3, e=89, f=897, g=-0.8, **kwargs): + pass +def _func_test_21(a, /, b, c, *, d=3, e=89, f=897, g=-0.8, **kwargs): + pass + + +def test_count_arguments(): + assert count_arguments(_func_test_1) == 0 + assert count_arguments(_func_test_2) == 1 + assert count_arguments(_func_test_3) == 2 + assert count_arguments(_func_test_4) == 1 + assert count_arguments(_func_test_5) == 2 + assert count_arguments(_func_test_6) == 0 + assert count_arguments(_func_test_7) == 3 + assert count_arguments(_func_test_8) == 2 + assert count_arguments(_func_test_9) == 4 + assert count_arguments(_func_test_10) == 0 + assert count_arguments(_func_test_11) == 2 + assert count_arguments(_func_test_12) == 0 + assert count_arguments(_func_test_13) == 1 + assert count_arguments(_func_test_14) == 1 + assert count_arguments(_func_test_15) == 3 + assert count_arguments(_func_test_16) == 2 + assert count_arguments(_func_test_17) == 7 + assert count_arguments(_func_test_18) == 1 + assert count_arguments(_func_test_19) == 2 + assert count_arguments(_func_test_20) == 7 + assert count_arguments(_func_test_21) == 7 + assert count_arguments(_func_test_1, count_variable_length_args=True) == 0 + assert count_arguments(_func_test_2, count_variable_length_args=True) == 1 + assert count_arguments(_func_test_3, count_variable_length_args=True) == 2 + assert count_arguments(_func_test_4, count_variable_length_args=True) == 1 + assert count_arguments(_func_test_5, count_variable_length_args=True) == 2 + assert count_arguments(_func_test_6, count_variable_length_args=True) == 1 + assert count_arguments(_func_test_7, count_variable_length_args=True) == 4 + assert count_arguments(_func_test_8, count_variable_length_args=True) == 3 + assert count_arguments(_func_test_9, count_variable_length_args=True) == 5 + assert count_arguments(_func_test_10, count_variable_length_args=True) == 1 + assert count_arguments(_func_test_11, count_variable_length_args=True) == 3 + assert count_arguments(_func_test_12, count_variable_length_args=True) == 2 + assert count_arguments(_func_test_13, count_variable_length_args=True) == 2 + assert count_arguments(_func_test_14, count_variable_length_args=True) == 3 + assert count_arguments(_func_test_15, count_variable_length_args=True) == 4 + assert count_arguments(_func_test_16, count_variable_length_args=True) == 4 + assert count_arguments(_func_test_17, count_variable_length_args=True) == 9 + assert count_arguments(_func_test_18, count_variable_length_args=True) == 1 + assert count_arguments(_func_test_19, count_variable_length_args=True) == 2 + assert count_arguments(_func_test_20, count_variable_length_args=True) == 9 + assert count_arguments(_func_test_21, count_variable_length_args=True) == 8 + + +def test_count_required_arguments(): + assert count_required_arguments(_func_test_1) == 0 + assert count_required_arguments(_func_test_2) == 1 + assert count_required_arguments(_func_test_3) == 2 + assert count_required_arguments(_func_test_4) == 0 + assert count_required_arguments(_func_test_5) == 1 + assert count_required_arguments(_func_test_6) == 0 + assert count_required_arguments(_func_test_7) == 3 + assert count_required_arguments(_func_test_8) == 0 + assert count_required_arguments(_func_test_9) == 2 + assert count_required_arguments(_func_test_10) == 0 + assert count_required_arguments(_func_test_11) == 2 + assert count_required_arguments(_func_test_12) == 0 + assert count_required_arguments(_func_test_13) == 0 + assert count_required_arguments(_func_test_14) == 1 + assert count_required_arguments(_func_test_15) == 2 + assert count_required_arguments(_func_test_16) == 0 + assert count_required_arguments(_func_test_17) == 3 + assert count_required_arguments(_func_test_18) == 1 + assert count_required_arguments(_func_test_19) == 1 + assert count_required_arguments(_func_test_20) == 3 + assert count_required_arguments(_func_test_21) == 3 + + +def test_count_optional_arguments(): + assert count_optional_arguments(_func_test_1) == 0 + assert count_optional_arguments(_func_test_2) == 0 + assert count_optional_arguments(_func_test_3) == 0 + assert count_optional_arguments(_func_test_4) == 1 + assert count_optional_arguments(_func_test_5) == 1 + assert count_optional_arguments(_func_test_6) == 0 + assert count_optional_arguments(_func_test_7) == 0 + assert count_optional_arguments(_func_test_8) == 2 + assert count_optional_arguments(_func_test_9) == 2 + assert count_optional_arguments(_func_test_10) == 0 + assert count_optional_arguments(_func_test_11) == 0 + assert count_optional_arguments(_func_test_12) == 0 + assert count_optional_arguments(_func_test_13) == 1 + assert count_optional_arguments(_func_test_14) == 0 + assert count_optional_arguments(_func_test_15) == 1 + assert count_optional_arguments(_func_test_16) == 2 + assert count_optional_arguments(_func_test_17) == 4 + assert count_optional_arguments(_func_test_18) == 0 + assert count_optional_arguments(_func_test_19) == 1 + assert count_optional_arguments(_func_test_20) == 4 + assert count_optional_arguments(_func_test_21) == 4 + +def test_has_variable_length_arguments(): + assert has_variable_length_arguments(_func_test_1) is False + assert has_variable_length_arguments(_func_test_2) is False + assert has_variable_length_arguments(_func_test_3) is False + assert has_variable_length_arguments(_func_test_4) is False + assert has_variable_length_arguments(_func_test_5) is False + assert has_variable_length_arguments(_func_test_6) is True + assert has_variable_length_arguments(_func_test_7) is True + assert has_variable_length_arguments(_func_test_8) is True + assert has_variable_length_arguments(_func_test_9) is True + assert has_variable_length_arguments(_func_test_10) is True + assert has_variable_length_arguments(_func_test_11) is True + assert has_variable_length_arguments(_func_test_12) is True + assert has_variable_length_arguments(_func_test_13) is True + assert has_variable_length_arguments(_func_test_14) is True + assert has_variable_length_arguments(_func_test_15) is True + assert has_variable_length_arguments(_func_test_16) is True + assert has_variable_length_arguments(_func_test_17) is True + assert has_variable_length_arguments(_func_test_18) is False + assert has_variable_length_arguments(_func_test_19) is False + assert has_variable_length_arguments(_func_test_20) is True + assert has_variable_length_arguments(_func_test_21) is True + assert has_variable_length_positional_arguments(_func_test_1) is False + assert has_variable_length_positional_arguments(_func_test_2) is False + assert has_variable_length_positional_arguments(_func_test_3) is False + assert has_variable_length_positional_arguments(_func_test_4) is False + assert has_variable_length_positional_arguments(_func_test_5) is False + assert has_variable_length_positional_arguments(_func_test_6) is True + assert has_variable_length_positional_arguments(_func_test_7) is True + assert has_variable_length_positional_arguments(_func_test_8) is True + assert has_variable_length_positional_arguments(_func_test_9) is True + assert has_variable_length_positional_arguments(_func_test_10) is False + assert has_variable_length_positional_arguments(_func_test_11) is False + assert has_variable_length_positional_arguments(_func_test_12) is True + assert has_variable_length_positional_arguments(_func_test_13) is False + assert has_variable_length_positional_arguments(_func_test_14) is True + assert has_variable_length_positional_arguments(_func_test_15) is False + assert has_variable_length_positional_arguments(_func_test_16) is True + assert has_variable_length_positional_arguments(_func_test_17) is True + assert has_variable_length_positional_arguments(_func_test_18) is False + assert has_variable_length_positional_arguments(_func_test_19) is False + assert has_variable_length_positional_arguments(_func_test_20) is True + assert has_variable_length_positional_arguments(_func_test_21) is False + assert has_variable_length_keyword_arguments(_func_test_1) is False + assert has_variable_length_keyword_arguments(_func_test_2) is False + assert has_variable_length_keyword_arguments(_func_test_3) is False + assert has_variable_length_keyword_arguments(_func_test_4) is False + assert has_variable_length_keyword_arguments(_func_test_5) is False + assert has_variable_length_keyword_arguments(_func_test_6) is False + assert has_variable_length_keyword_arguments(_func_test_7) is False + assert has_variable_length_keyword_arguments(_func_test_8) is False + assert has_variable_length_keyword_arguments(_func_test_9) is False + assert has_variable_length_keyword_arguments(_func_test_10) is True + assert has_variable_length_keyword_arguments(_func_test_11) is True + assert has_variable_length_keyword_arguments(_func_test_12) is True + assert has_variable_length_keyword_arguments(_func_test_13) is True + assert has_variable_length_keyword_arguments(_func_test_14) is True + assert has_variable_length_keyword_arguments(_func_test_15) is True + assert has_variable_length_keyword_arguments(_func_test_16) is True + assert has_variable_length_keyword_arguments(_func_test_17) is True + assert has_variable_length_keyword_arguments(_func_test_18) is False + assert has_variable_length_keyword_arguments(_func_test_19) is False + assert has_variable_length_keyword_arguments(_func_test_20) is True + assert has_variable_length_keyword_arguments(_func_test_21) is True From f2f98f253a4c4792244eefbf040e11a7acaa6000 Mon Sep 17 00:00:00 2001 From: Frederik Van der Veken Date: Thu, 9 Jan 2025 19:20:10 +0100 Subject: [PATCH 07/26] Trying to make singleton work with inheritance - WIP --- tests/test_class_tools.py | 214 +++++++++++++++++++++++++++++++++++++- xaux/tools/class_tools.py | 45 ++++---- 2 files changed, 233 insertions(+), 26 deletions(-) diff --git a/tests/test_class_tools.py b/tests/test_class_tools.py index 05bbdbc..3cd9503 100644 --- a/tests/test_class_tools.py +++ b/tests/test_class_tools.py @@ -4,8 +4,11 @@ # ######################################### # import pytest +import re + from xaux import singleton, ClassProperty, ClassPropertyMeta + def test_singleton(): # Non-singleton example. class NonSingletonClass: @@ -29,9 +32,9 @@ def __init__(self, value=3): self.value = value # Initialise with default value - assert not hasattr(SingletonClass, 'instance') + assert SingletonClass._singleton_instance is None instance1 = SingletonClass() - assert hasattr(SingletonClass, 'instance') + assert SingletonClass._singleton_instance is not None assert instance1.value == 3 # Initialise with specific value @@ -55,7 +58,7 @@ def __init__(self, value=3): # Remove the singleton SingletonClass.delete() - assert not hasattr(SingletonClass, 'instance') + assert SingletonClass._singleton_instance is None with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass " + "has been invalidated!"): instance1.value @@ -68,7 +71,7 @@ def __init__(self, value=3): # First initialisation with specific value instance4 = SingletonClass(value=8) - assert hasattr(SingletonClass, 'instance') + assert SingletonClass._singleton_instance is not None assert instance4.value == 8 assert instance1 is not instance4 assert instance2 is not instance4 @@ -78,6 +81,15 @@ def __init__(self, value=3): assert id(instance3) != id(instance4) +def test_singleton_structure(): + with pytest.raises(ValueError, match=re.escape("Cannot create a singleton with an __init__ " + + "method that has more than one required argument (only 'self' is allowed)!")): + @singleton + class SingletonClass: + def __init__(self, value): + self.value = value + + def test_get_self(): @singleton class SingletonClass: @@ -181,3 +193,197 @@ def __init__(self, value=3): assert id(self6) != id(self5) assert self6.value == -3 + +def test_singleton_inheritance(): + class NonSingletonParent: + def __init__(self, value1=3): + self.value1 = value1 + + @singleton + class SingletonParent: + def __init__(self, value1=7): + self.value1 = value1 + + @singleton + class SingletonChild1(NonSingletonParent): + def __init__(self, *args, value2=17, **kwargs): + self.value2 = value2 + super().__init__(*args, **kwargs) + + class SingletonChild2(SingletonParent): + def __init__(self, *args, value2=17, **kwargs): + self.value2 = value2 + super().__init__(*args, **kwargs) + + class SingletonChild3(SingletonParent): + def __init__(self, *args, value2=17, **kwargs): + self.value2 = value2 + super().__init__(*args, **kwargs) + + @singleton + class SingletonChild4(SingletonParent): + def __init__(self, *args, value2=17, **kwargs): + self.value2 = value2 + super().__init__(*args, **kwargs) + + # Test the non-singleton parent class + parent1_instance1 = NonSingletonParent(value1=8) + assert parent1_instance1.value1 == 8 + parent1_instance2 = NonSingletonParent(value1=9) + assert parent1_instance1 is not parent1_instance2 + assert parent1_instance1.value1 == 8 + assert parent1_instance2.value1 == 9 + + # Test the singleton child class + child1_instance1 = SingletonChild1() + assert child1_instance1 is not parent1_instance1 + assert child1_instance1.value1 == 3 + assert child1_instance1.value2 == 17 + child1_instance2 = SingletonChild1(value1=-2) + assert child1_instance2 is child1_instance1 + assert child1_instance2 is not parent1_instance1 + assert child1_instance1.value1 == -2 + assert child1_instance1.value2 == 17 + child1_instance3 = SingletonChild1(value2=-9) + assert child1_instance3 is child1_instance1 + assert child1_instance3 is not parent1_instance1 + assert child1_instance1.value1 == -2 + assert child1_instance1.value2 == -9 + child1_instance4 = SingletonChild1(value1=789, value2=-78) + assert child1_instance4 is child1_instance1 + assert child1_instance4 is not parent1_instance1 + assert child1_instance1.value1 == 789 + assert child1_instance1.value2 == -78 + + # Assert the (non-singleton) parent is not influenced by the child + parent1_instance3 = NonSingletonParent(value1=23) + assert parent1_instance1 is not parent1_instance2 + assert parent1_instance3 is not parent1_instance1 + assert child1_instance1 is not parent1_instance1 + assert child1_instance2 is not parent1_instance1 + assert child1_instance3 is not parent1_instance1 + assert child1_instance4 is not parent1_instance1 + assert child1_instance1 is not parent1_instance2 + assert child1_instance2 is not parent1_instance2 + assert child1_instance3 is not parent1_instance2 + assert child1_instance4 is not parent1_instance2 + assert child1_instance1 is not parent1_instance3 + assert child1_instance2 is not parent1_instance3 + assert child1_instance3 is not parent1_instance3 + assert child1_instance4 is not parent1_instance3 + assert parent1_instance1.value1 == 8 + assert parent1_instance2.value1 == 9 + assert parent1_instance3.value1 == 23 + assert child1_instance1.value1 == 789 + assert child1_instance1.value2 == -78 + + # Test the singleton parent class + parent2_instance1 = SingletonParent(value1=8) + assert parent2_instance1.value1 == 8 + parent2_instance2 = SingletonParent(value1=9) + assert parent2_instance1 is parent2_instance2 + assert parent2_instance1.value1 == 9 + assert parent2_instance2.value1 == 9 + + # Test the singleton child class + child2_instance1 = SingletonChild2() + assert child2_instance1 is not parent2_instance1 + assert child2_instance1.value1 == 3 + assert child2_instance1.value2 == 17 + child2_instance2 = SingletonChild2(value1=-2) + assert child2_instance2 is child2_instance1 + assert child2_instance2 is not parent2_instance1 + assert child2_instance1.value1 == -2 + assert child2_instance1.value2 == 17 + child2_instance3 = SingletonChild2(value2=-9) + assert child2_instance3 is child2_instance1 + assert child2_instance3 is not parent2_instance1 + assert child2_instance1.value1 == -2 + assert child2_instance1.value2 == -9 + child2_instance4 = SingletonChild2(value1=789, value2=-78) + assert child2_instance4 is child2_instance1 + assert child2_instance4 is not parent2_instance1 + assert child2_instance1.value1 == 789 + assert child2_instance1.value2 == -78 + + # Assert the (singleton) parent is not influenced by the child + parent2_instance3 = SingletonParent(value1=23) + assert parent2_instance1 is parent2_instance2 + assert parent2_instance3 is parent2_instance1 + assert child2_instance1 is not parent2_instance1 + assert child2_instance2 is not parent2_instance1 + assert child2_instance3 is not parent2_instance1 + assert child2_instance4 is not parent2_instance1 + assert child2_instance1 is not parent2_instance2 + assert child2_instance2 is not parent2_instance2 + assert child2_instance3 is not parent2_instance2 + assert child2_instance4 is not parent2_instance2 + assert child2_instance1 is not parent2_instance3 + assert child2_instance2 is not parent2_instance3 + assert child2_instance3 is not parent2_instance3 + assert child2_instance4 is not parent2_instance3 + assert parent2_instance1.value1 == 23 + assert parent2_instance2.value1 == 23 + assert parent2_instance3.value1 == 23 + assert child2_instance1.value1 == 789 + assert child2_instance1.value2 == -78 + + # Another class with the same parent should be a different singleton + child3_instance1 = SingletonChild3() + assert child3_instance1 is not child2_instance1 + + # Test the other singleton child class + child4_instance1 = SingletonChild4() + assert child4_instance1 is not parent2_instance1 + assert child4_instance1 is not child1_instance1 + assert child4_instance1 is not child2_instance1 + assert child4_instance1 is not child3_instance1 + assert child4_instance1.value1 == 3 + assert child4_instance1.value2 == 17 + child4_instance2 = SingletonChild4(value1=-2) + assert child4_instance2 is child4_instance1 + assert child4_instance2 is not parent2_instance1 + assert child4_instance1.value1 == -2 + assert child4_instance1.value2 == 17 + child4_instance3 = SingletonChild4(value2=-9) + assert child4_instance3 is child4_instance1 + assert child4_instance3 is not parent2_instance1 + assert child4_instance1.value1 == -2 + assert child4_instance1.value2 == -9 + child4_instance4 = SingletonChild4(value1=789, value2=-78) + assert child4_instance4 is child4_instance1 + assert child4_instance4 is not parent2_instance1 + assert child4_instance1.value1 == 789 + assert child4_instance1.value2 == -78 + + # Assert the (singleton) parent is not influenced by the child + parent2_instance4 = SingletonParent(value1=43) + assert parent2_instance1 is parent2_instance2 + assert parent2_instance2 is parent2_instance3 + assert parent2_instance3 is parent2_instance1 + assert child4_instance1 is not parent2_instance1 + assert child4_instance2 is not parent2_instance1 + assert child4_instance3 is not parent2_instance1 + assert child4_instance4 is not parent2_instance1 + assert child4_instance1 is not parent2_instance2 + assert child4_instance2 is not parent2_instance2 + assert child4_instance3 is not parent2_instance2 + assert child4_instance4 is not parent2_instance2 + assert child4_instance1 is not parent2_instance3 + assert child4_instance2 is not parent2_instance3 + assert child4_instance3 is not parent2_instance3 + assert child4_instance4 is not parent2_instance3 + assert child4_instance1 is not parent2_instance4 + assert child4_instance2 is not parent2_instance4 + assert child4_instance3 is not parent2_instance4 + assert child4_instance4 is not parent2_instance4 + assert parent2_instance1.value1 == 43 + assert parent2_instance2.value1 == 43 + assert parent2_instance3.value1 == 43 + assert child4_instance1.value1 == 789 + assert child4_instance1.value2 == -78 + + # test deletion + +def test_singleton_with_custom_new_and_init(): + pass diff --git a/xaux/tools/class_tools.py b/xaux/tools/class_tools.py index 55d0ab4..062a792 100644 --- a/xaux/tools/class_tools.py +++ b/xaux/tools/class_tools.py @@ -11,19 +11,20 @@ def singleton(cls, allow_underscore_vars_in_init=False): # Monkey-patch the __new__ method to create a singleton - original_new = cls.__new__ if '__new__' in cls.__dict__ else None + original_new = cls.__dict__.get('__new__', None) + cls._singleton_instance = None def singleton_new(cls, *args, **kwargs): - if not hasattr(cls, 'instance'): - cls.instance = (original_new(cls, *args, **kwargs) \ + if cls._singleton_instance is None: # Check if the class is already initialised + cls._singleton_instance = (original_new(cls, *args, **kwargs) \ if original_new \ else super(cls, cls).__new__(cls)) - cls.instance._initialised = False - cls.instance._valid = True - return cls.instance + cls._singleton_instance._initialised = False + cls._singleton_instance._valid = True + return cls._singleton_instance cls.__new__ = singleton_new # Monkey-patch the __init__ method to set the singleton fields - original_init = cls.__init__ if '__init__' in cls.__dict__ else None + original_init = cls.__dict__.get('__init__', None) if original_init: if count_required_arguments(original_init) > 1: raise ValueError(f"Cannot create a singleton with an __init__ method that " @@ -44,6 +45,17 @@ def singleton_init(self, *args, **kwargs): setattr(self, kk, vv) cls.__init__ = singleton_init + # Monkey-patch the __getattribute__ method to assert the instance belongs to the current singleton + original_getattribute = cls.__dict__.get('__getattribute__', None) + def singleton_getattribute(self, name): + if not super(cls, self).__getattribute__('_valid'): + raise RuntimeError(f"This instance of the singleton {cls.__name__} has been invalidated!") + if original_getattribute: + return original_getattribute(self, name) + else: + return super(cls, self).__getattribute__(name) + cls.__getattribute__ = singleton_getattribute + # Define the get_self method @classmethod def get_self(cls, **kwargs): @@ -51,7 +63,7 @@ def get_self(cls, **kwargs): # (to recognise get the allowed fields) cls() filtered_kwargs = {key: value for key, value in kwargs.items() - if hasattr(cls, key) or hasattr(cls.instance, key)} + if hasattr(cls, key) or hasattr(cls._singleton_instance, key)} if not allow_underscore_vars_in_init: filtered_kwargs = {key: value for key, value in filtered_kwargs.items() if not key.startswith('_')} @@ -61,22 +73,11 @@ def get_self(cls, **kwargs): # Define the delete method @classmethod def delete(cls): - if hasattr(cls, 'instance'): - cls.instance._valid = False # Invalidate existing instances - del cls.instance + if cls._singleton_instance is not None: + cls._singleton_instance._valid = False # Invalidate existing instances + cls._singleton_instance = None cls.delete = delete - # Monkey-patch the __getattribute__ method to assert the instance belongs to the current singleton - original_getattribute = cls.__getattribute__ if '__getattribute__' in cls.__dict__ else None - def singleton_getattribute(self, name): - if not super(cls, self).__getattribute__('_valid'): - raise RuntimeError(f"This instance of the singleton {cls.__name__} has been invalidated!") - if original_getattribute: - return original_getattribute(self, name) - else: - return super(cls, self).__getattribute__(name) - cls.__getattribute__ = singleton_getattribute - return cls From 47a2e4f758dfddfd0946d3e05dac5dadeff305ee Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Thu, 16 Jan 2025 02:22:16 +0100 Subject: [PATCH 08/26] Expanded singleton tests to be more profound --- tests/test_class_tools.py | 389 ------------------ tests/test_singleton.py | 810 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 810 insertions(+), 389 deletions(-) delete mode 100644 tests/test_class_tools.py create mode 100644 tests/test_singleton.py diff --git a/tests/test_class_tools.py b/tests/test_class_tools.py deleted file mode 100644 index 3cd9503..0000000 --- a/tests/test_class_tools.py +++ /dev/null @@ -1,389 +0,0 @@ -# copyright ############################### # -# This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # -# ######################################### # - -import pytest -import re - -from xaux import singleton, ClassProperty, ClassPropertyMeta - - -def test_singleton(): - # Non-singleton example. - class NonSingletonClass: - def __init__(self, value=3): - self.value = value - - instance1 = NonSingletonClass() - assert instance1.value == 3 - instance2 = NonSingletonClass(value=5) - assert instance1 is not instance2 - assert id(instance1) != id(instance2) - assert instance1.value == 3 - assert instance2.value == 5 - instance1.value = 7 - assert instance1.value == 7 - assert instance2.value == 5 - - @singleton - class SingletonClass: - def __init__(self, value=3): - self.value = value - - # Initialise with default value - assert SingletonClass._singleton_instance is None - instance1 = SingletonClass() - assert SingletonClass._singleton_instance is not None - assert instance1.value == 3 - - # Initialise with specific value - instance2 = SingletonClass(value=5) - assert instance1 is instance2 - assert id(instance1) == id(instance2) - assert instance1.value == 5 - assert instance2.value == 5 - - # Initialise with default value again - this should not change the value - instance3 = SingletonClass() - assert instance1.value == 5 - assert instance2.value == 5 - assert instance3.value == 5 - - # Change the value of the instance - instance1.value = 7 - assert instance1.value == 7 - assert instance2.value == 7 - assert instance3.value == 7 - - # Remove the singleton - SingletonClass.delete() - assert SingletonClass._singleton_instance is None - with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass " - + "has been invalidated!"): - instance1.value - with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass " - + "has been invalidated!"): - instance2.value - with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass " - + "has been invalidated!"): - instance3.value - - # First initialisation with specific value - instance4 = SingletonClass(value=8) - assert SingletonClass._singleton_instance is not None - assert instance4.value == 8 - assert instance1 is not instance4 - assert instance2 is not instance4 - assert instance3 is not instance4 - assert id(instance1) != id(instance4) - assert id(instance2) != id(instance4) - assert id(instance3) != id(instance4) - - -def test_singleton_structure(): - with pytest.raises(ValueError, match=re.escape("Cannot create a singleton with an __init__ " - + "method that has more than one required argument (only 'self' is allowed)!")): - @singleton - class SingletonClass: - def __init__(self, value): - self.value = value - - -def test_get_self(): - @singleton - class SingletonClass: - def __init__(self, value=3): - self.value = value - - # Initialise with default value - instance = SingletonClass() - instance.value = 10 - - # Get self with default value - self1 = SingletonClass.get_self() - assert self1 is instance - assert id(self1) == id(instance) - assert self1.value == 10 - assert instance.value == 10 - - # Get self with specific value - self2 = SingletonClass.get_self(value=11) - assert self2 is instance - assert self2 is self1 - assert id(self2) == id(instance) - assert id(self2) == id(self1) - assert instance.value == 11 - assert self1.value == 11 - assert self2.value == 11 - - # Get self with non-existing attribute - self3 = SingletonClass.get_self(non_existing_attribute=13) - assert self3 is instance - assert self3 is self1 - assert self3 is self2 - assert id(self3) == id(instance) - assert id(self3) == id(self1) - assert id(self3) == id(self2) - assert instance.value == 11 - assert self1.value == 11 - assert self2.value == 11 - assert self3.value == 11 - assert not hasattr(SingletonClass, 'non_existing_attribute') - assert not hasattr(instance, 'non_existing_attribute') - assert not hasattr(self1, 'non_existing_attribute') - assert not hasattr(self2, 'non_existing_attribute') - assert not hasattr(self3, 'non_existing_attribute') - - # Get self with specific value and non-existing attribute - self4 = SingletonClass.get_self(value=12, non_existing_attribute=13) - assert self4 is instance - assert self4 is self1 - assert self4 is self2 - assert self4 is self3 - assert id(self4) == id(instance) - assert id(self4) == id(self1) - assert id(self4) == id(self2) - assert id(self4) == id(self3) - assert instance.value == 12 - assert self1.value == 12 - assert self2.value == 12 - assert self3.value == 12 - assert self4.value == 12 - assert not hasattr(SingletonClass, 'non_existing_attribute') - assert not hasattr(instance, 'non_existing_attribute') - assert not hasattr(self1, 'non_existing_attribute') - assert not hasattr(self2, 'non_existing_attribute') - assert not hasattr(self3, 'non_existing_attribute') - assert not hasattr(self4, 'non_existing_attribute') - - # Remove the singleton - SingletonClass.delete() - - # Initialise with get self with default value - self5 = SingletonClass.get_self() - assert self5 is not instance - assert self5 is not self1 - assert self5 is not self2 - assert self5 is not self3 - assert self5 is not self4 - assert id(self5) != id(instance) - assert id(self5) != id(self1) - assert id(self5) != id(self2) - assert id(self5) != id(self3) - assert id(self5) != id(self4) - assert self5.value == 3 - - # Remove the singleton - SingletonClass.delete() - - # Initialise with get self with specific value - self6 = SingletonClass.get_self(value=-3) - assert self6 is not instance - assert self6 is not self1 - assert self6 is not self2 - assert self6 is not self3 - assert self6 is not self4 - assert self6 is not self5 - assert id(self6) != id(instance) - assert id(self6) != id(self1) - assert id(self6) != id(self2) - assert id(self6) != id(self3) - assert id(self6) != id(self4) - assert id(self6) != id(self5) - assert self6.value == -3 - - -def test_singleton_inheritance(): - class NonSingletonParent: - def __init__(self, value1=3): - self.value1 = value1 - - @singleton - class SingletonParent: - def __init__(self, value1=7): - self.value1 = value1 - - @singleton - class SingletonChild1(NonSingletonParent): - def __init__(self, *args, value2=17, **kwargs): - self.value2 = value2 - super().__init__(*args, **kwargs) - - class SingletonChild2(SingletonParent): - def __init__(self, *args, value2=17, **kwargs): - self.value2 = value2 - super().__init__(*args, **kwargs) - - class SingletonChild3(SingletonParent): - def __init__(self, *args, value2=17, **kwargs): - self.value2 = value2 - super().__init__(*args, **kwargs) - - @singleton - class SingletonChild4(SingletonParent): - def __init__(self, *args, value2=17, **kwargs): - self.value2 = value2 - super().__init__(*args, **kwargs) - - # Test the non-singleton parent class - parent1_instance1 = NonSingletonParent(value1=8) - assert parent1_instance1.value1 == 8 - parent1_instance2 = NonSingletonParent(value1=9) - assert parent1_instance1 is not parent1_instance2 - assert parent1_instance1.value1 == 8 - assert parent1_instance2.value1 == 9 - - # Test the singleton child class - child1_instance1 = SingletonChild1() - assert child1_instance1 is not parent1_instance1 - assert child1_instance1.value1 == 3 - assert child1_instance1.value2 == 17 - child1_instance2 = SingletonChild1(value1=-2) - assert child1_instance2 is child1_instance1 - assert child1_instance2 is not parent1_instance1 - assert child1_instance1.value1 == -2 - assert child1_instance1.value2 == 17 - child1_instance3 = SingletonChild1(value2=-9) - assert child1_instance3 is child1_instance1 - assert child1_instance3 is not parent1_instance1 - assert child1_instance1.value1 == -2 - assert child1_instance1.value2 == -9 - child1_instance4 = SingletonChild1(value1=789, value2=-78) - assert child1_instance4 is child1_instance1 - assert child1_instance4 is not parent1_instance1 - assert child1_instance1.value1 == 789 - assert child1_instance1.value2 == -78 - - # Assert the (non-singleton) parent is not influenced by the child - parent1_instance3 = NonSingletonParent(value1=23) - assert parent1_instance1 is not parent1_instance2 - assert parent1_instance3 is not parent1_instance1 - assert child1_instance1 is not parent1_instance1 - assert child1_instance2 is not parent1_instance1 - assert child1_instance3 is not parent1_instance1 - assert child1_instance4 is not parent1_instance1 - assert child1_instance1 is not parent1_instance2 - assert child1_instance2 is not parent1_instance2 - assert child1_instance3 is not parent1_instance2 - assert child1_instance4 is not parent1_instance2 - assert child1_instance1 is not parent1_instance3 - assert child1_instance2 is not parent1_instance3 - assert child1_instance3 is not parent1_instance3 - assert child1_instance4 is not parent1_instance3 - assert parent1_instance1.value1 == 8 - assert parent1_instance2.value1 == 9 - assert parent1_instance3.value1 == 23 - assert child1_instance1.value1 == 789 - assert child1_instance1.value2 == -78 - - # Test the singleton parent class - parent2_instance1 = SingletonParent(value1=8) - assert parent2_instance1.value1 == 8 - parent2_instance2 = SingletonParent(value1=9) - assert parent2_instance1 is parent2_instance2 - assert parent2_instance1.value1 == 9 - assert parent2_instance2.value1 == 9 - - # Test the singleton child class - child2_instance1 = SingletonChild2() - assert child2_instance1 is not parent2_instance1 - assert child2_instance1.value1 == 3 - assert child2_instance1.value2 == 17 - child2_instance2 = SingletonChild2(value1=-2) - assert child2_instance2 is child2_instance1 - assert child2_instance2 is not parent2_instance1 - assert child2_instance1.value1 == -2 - assert child2_instance1.value2 == 17 - child2_instance3 = SingletonChild2(value2=-9) - assert child2_instance3 is child2_instance1 - assert child2_instance3 is not parent2_instance1 - assert child2_instance1.value1 == -2 - assert child2_instance1.value2 == -9 - child2_instance4 = SingletonChild2(value1=789, value2=-78) - assert child2_instance4 is child2_instance1 - assert child2_instance4 is not parent2_instance1 - assert child2_instance1.value1 == 789 - assert child2_instance1.value2 == -78 - - # Assert the (singleton) parent is not influenced by the child - parent2_instance3 = SingletonParent(value1=23) - assert parent2_instance1 is parent2_instance2 - assert parent2_instance3 is parent2_instance1 - assert child2_instance1 is not parent2_instance1 - assert child2_instance2 is not parent2_instance1 - assert child2_instance3 is not parent2_instance1 - assert child2_instance4 is not parent2_instance1 - assert child2_instance1 is not parent2_instance2 - assert child2_instance2 is not parent2_instance2 - assert child2_instance3 is not parent2_instance2 - assert child2_instance4 is not parent2_instance2 - assert child2_instance1 is not parent2_instance3 - assert child2_instance2 is not parent2_instance3 - assert child2_instance3 is not parent2_instance3 - assert child2_instance4 is not parent2_instance3 - assert parent2_instance1.value1 == 23 - assert parent2_instance2.value1 == 23 - assert parent2_instance3.value1 == 23 - assert child2_instance1.value1 == 789 - assert child2_instance1.value2 == -78 - - # Another class with the same parent should be a different singleton - child3_instance1 = SingletonChild3() - assert child3_instance1 is not child2_instance1 - - # Test the other singleton child class - child4_instance1 = SingletonChild4() - assert child4_instance1 is not parent2_instance1 - assert child4_instance1 is not child1_instance1 - assert child4_instance1 is not child2_instance1 - assert child4_instance1 is not child3_instance1 - assert child4_instance1.value1 == 3 - assert child4_instance1.value2 == 17 - child4_instance2 = SingletonChild4(value1=-2) - assert child4_instance2 is child4_instance1 - assert child4_instance2 is not parent2_instance1 - assert child4_instance1.value1 == -2 - assert child4_instance1.value2 == 17 - child4_instance3 = SingletonChild4(value2=-9) - assert child4_instance3 is child4_instance1 - assert child4_instance3 is not parent2_instance1 - assert child4_instance1.value1 == -2 - assert child4_instance1.value2 == -9 - child4_instance4 = SingletonChild4(value1=789, value2=-78) - assert child4_instance4 is child4_instance1 - assert child4_instance4 is not parent2_instance1 - assert child4_instance1.value1 == 789 - assert child4_instance1.value2 == -78 - - # Assert the (singleton) parent is not influenced by the child - parent2_instance4 = SingletonParent(value1=43) - assert parent2_instance1 is parent2_instance2 - assert parent2_instance2 is parent2_instance3 - assert parent2_instance3 is parent2_instance1 - assert child4_instance1 is not parent2_instance1 - assert child4_instance2 is not parent2_instance1 - assert child4_instance3 is not parent2_instance1 - assert child4_instance4 is not parent2_instance1 - assert child4_instance1 is not parent2_instance2 - assert child4_instance2 is not parent2_instance2 - assert child4_instance3 is not parent2_instance2 - assert child4_instance4 is not parent2_instance2 - assert child4_instance1 is not parent2_instance3 - assert child4_instance2 is not parent2_instance3 - assert child4_instance3 is not parent2_instance3 - assert child4_instance4 is not parent2_instance3 - assert child4_instance1 is not parent2_instance4 - assert child4_instance2 is not parent2_instance4 - assert child4_instance3 is not parent2_instance4 - assert child4_instance4 is not parent2_instance4 - assert parent2_instance1.value1 == 43 - assert parent2_instance2.value1 == 43 - assert parent2_instance3.value1 == 43 - assert child4_instance1.value1 == 789 - assert child4_instance1.value2 == -78 - - # test deletion - -def test_singleton_with_custom_new_and_init(): - pass diff --git a/tests/test_singleton.py b/tests/test_singleton.py new file mode 100644 index 0000000..7fd0c50 --- /dev/null +++ b/tests/test_singleton.py @@ -0,0 +1,810 @@ +# copyright ############################### # +# This file is part of the Xaux Package. # +# Copyright (c) CERN, 2024. # +# ######################################### # + +import pytest +import re + +from xaux import singleton + + +# We are overly verbose in these tests, comparing every time again all instances to each other. +# This is to make sure that the singletons are really singletons and that they do not interfere +# with each other. It's an important overhead to ensure we deeply test the global states, because +# if we would pytest.parametrize this, we might be copying state and not realising this. + + +def test_singleton(): + # Non-singleton example. + class NonSingletonClass: + def __init__(self, value=3): + self.value = value + + instance1 = NonSingletonClass() + assert instance1.value == 3 + instance2 = NonSingletonClass(value=5) + assert instance1 is not instance2 + assert id(instance1) != id(instance2) + assert instance1.value == 3 + assert instance2.value == 5 + instance1.value = 7 + assert instance1.value == 7 + assert instance2.value == 5 + + @singleton + class SingletonClass1: + def __init__(self, value=3): + self.value = value + + # Initialise with default value + assert SingletonClass1._singleton_instance is None + instance1 = SingletonClass1() + assert SingletonClass1._singleton_instance is not None + assert instance1.value == 3 + + # Initialise with specific value + instance2 = SingletonClass1(value=5) + assert instance1 is instance2 + assert id(instance1) == id(instance2) + assert instance1.value == 5 + assert instance2.value == 5 + + # Initialise with default value again - this should not change the value + instance3 = SingletonClass1() + assert instance2 is instance3 + assert id(instance2) == id(instance3) + assert instance1.value == 5 + assert instance2.value == 5 + assert instance3.value == 5 + + # Change the value of the instance + instance1.value = 7 + assert instance1.value == 7 + assert instance2.value == 7 + assert instance3.value == 7 + + # Remove the singleton + SingletonClass1.delete() + assert SingletonClass1._singleton_instance is None + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass1 " + + "has been invalidated!"): + instance1.value + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass1 " + + "has been invalidated!"): + instance2.value + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass1 " + + "has been invalidated!"): + instance3.value + + # First initialisation with specific value + instance4 = SingletonClass1(value=8) + assert SingletonClass1._singleton_instance is not None + assert instance4.value == 8 + assert instance1 is not instance4 + assert instance2 is not instance4 + assert instance3 is not instance4 + assert id(instance1) != id(instance4) + assert id(instance2) != id(instance4) + assert id(instance3) != id(instance4) + + # Clean up + SingletonClass1.delete() + assert SingletonClass1._singleton_instance is None + + # Test double deletion + SingletonClass1.delete() + assert SingletonClass1._singleton_instance is None + + +def test_singleton_structure(): + with pytest.raises(ValueError, match=re.escape("Cannot create a singleton with an __init__ " + + "method that has more than one required argument (only 'self' is allowed)!")): + @singleton + class SingletonClass2: + def __init__(self, value): + self.value = value + + +def test_nonsingleton_inheritance(): + class NonSingletonParent: + def __init__(self, value1=3): + print("In NonSingletonParent __init__") + self.value1 = value1 + + @singleton + class SingletonChild1(NonSingletonParent): + def __init__(self, *args, value2=17, **kwargs): + print("In SingletonChild1 __init__") + self.value2 = value2 + super().__init__(*args, **kwargs) + + @singleton + class SingletonChild2(NonSingletonParent): + def __init__(self, *args, value2=-13, **kwargs): + print("In SingletonChild2 __init__") + self.value2 = value2 + super().__init__(*args, **kwargs) + + # Test the non-singleton parent class + ns_parent_instance1 = NonSingletonParent(value1=8) + assert ns_parent_instance1.value1 == 8 + ns_parent_instance2 = NonSingletonParent(value1=9) + assert ns_parent_instance1 is not ns_parent_instance2 + assert ns_parent_instance1.value1 == 8 + assert ns_parent_instance2.value1 == 9 + + # Test the singleton child class + child1_instance1 = SingletonChild1() + assert child1_instance1 is not ns_parent_instance1 + assert child1_instance1.value1 == 3 + assert child1_instance1.value2 == 17 + child1_instance2 = SingletonChild1(value1=-2) + assert child1_instance2 is child1_instance1 + assert child1_instance2 is not ns_parent_instance1 + assert child1_instance1.value1 == -2 + assert child1_instance1.value2 == 17 + child1_instance3 = SingletonChild1(value2=-9) + assert child1_instance3 is child1_instance1 + assert child1_instance3 is not ns_parent_instance1 + assert child1_instance1.value1 == -2 + assert child1_instance1.value2 == -9 + child1_instance4 = SingletonChild1(value1=789, value2=-78) + assert child1_instance4 is child1_instance1 + assert child1_instance4 is not ns_parent_instance1 + assert child1_instance1.value1 == 789 + assert child1_instance1.value2 == -78 + + # Test the other singleton child class + child2_instance1 = SingletonChild2() + assert child2_instance1 is not ns_parent_instance1 + assert child2_instance1 is not ns_parent_instance2 + assert child2_instance1 is not child1_instance1 + assert child2_instance1 is not child1_instance2 + assert child2_instance1 is not child1_instance3 + assert child2_instance1 is not child1_instance4 + assert child2_instance1.value1 == 3 + assert child2_instance1.value2 == -13 + child2_instance2 = SingletonChild2(value1=-4) + assert child2_instance2 is child2_instance1 + assert child2_instance2 is not ns_parent_instance1 + assert child2_instance2 is not ns_parent_instance2 + assert child2_instance2 is not child1_instance1 + assert child2_instance2 is not child1_instance2 + assert child2_instance2 is not child1_instance3 + assert child2_instance2 is not child1_instance4 + assert child2_instance1.value1 == -4 + assert child2_instance1.value2 == -13 + child2_instance3 = SingletonChild2(value2=-9) + assert child2_instance3 is child2_instance1 + assert child2_instance3 is child2_instance2 + assert child2_instance3 is not ns_parent_instance1 + assert child2_instance3 is not ns_parent_instance2 + assert child2_instance3 is not child1_instance1 + assert child2_instance3 is not child1_instance2 + assert child2_instance3 is not child1_instance3 + assert child2_instance3 is not child1_instance4 + assert child2_instance1.value1 == -4 + assert child2_instance1.value2 == -9 + child2_instance4 = SingletonChild2(value1=127, value2=99) + assert child2_instance4 is child2_instance1 + assert child2_instance4 is child2_instance2 + assert child2_instance4 is child2_instance3 + assert child2_instance4 is not ns_parent_instance1 + assert child2_instance4 is not ns_parent_instance2 + assert child2_instance4 is not child1_instance1 + assert child2_instance4 is not child1_instance2 + assert child2_instance4 is not child1_instance3 + assert child2_instance4 is not child1_instance4 + assert child2_instance1.value1 == 127 + assert child2_instance1.value2 == 99 + + # Assert the (non-singleton) ns_parent is not influenced by the children + ns_parent_instance3 = NonSingletonParent(value1=23) + assert ns_parent_instance1 is not ns_parent_instance2 + assert ns_parent_instance3 is not ns_parent_instance1 + assert child1_instance1 is not ns_parent_instance1 + assert child1_instance2 is not ns_parent_instance1 + assert child1_instance3 is not ns_parent_instance1 + assert child1_instance4 is not ns_parent_instance1 + assert child1_instance1 is not ns_parent_instance2 + assert child1_instance2 is not ns_parent_instance2 + assert child1_instance3 is not ns_parent_instance2 + assert child1_instance4 is not ns_parent_instance2 + assert child1_instance1 is not ns_parent_instance3 + assert child1_instance2 is not ns_parent_instance3 + assert child1_instance3 is not ns_parent_instance3 + assert child1_instance4 is not ns_parent_instance3 + assert child2_instance1 is not ns_parent_instance1 + assert child2_instance2 is not ns_parent_instance1 + assert child2_instance3 is not ns_parent_instance1 + assert child2_instance4 is not ns_parent_instance1 + assert child2_instance1 is not ns_parent_instance2 + assert child2_instance2 is not ns_parent_instance2 + assert child2_instance3 is not ns_parent_instance2 + assert child2_instance4 is not ns_parent_instance2 + assert child2_instance1 is not ns_parent_instance3 + assert child2_instance2 is not ns_parent_instance3 + assert child2_instance3 is not ns_parent_instance3 + assert child2_instance4 is not ns_parent_instance3 + assert ns_parent_instance1.value1 == 8 + assert ns_parent_instance2.value1 == 9 + assert ns_parent_instance3.value1 == 23 + assert child1_instance1.value1 == 789 + assert child1_instance1.value2 == -78 + assert child2_instance1.value1 == 127 + assert child2_instance1.value2 == 99 + + # Clean up + SingletonChild1.delete() + assert SingletonChild1._singleton_instance is None + SingletonChild2.delete() + assert SingletonChild2._singleton_instance is None + + +def test_singleton_inheritance(): + @singleton + class SingletonParent1: + def __init__(self, value1=7): + print("In SingletonParent1 __init__") + self.value1 = value1 + + class SingletonChild3(SingletonParent1): + def __init__(self, *args, value2='lop', **kwargs): + print("In SingletonChild3 __init__") + self.value2 = value2 + super().__init__(*args, **kwargs) + + class SingletonChild4(SingletonParent1): + def __init__(self, *args, value2=0, **kwargs): + print("In SingletonChild4 __init__") + self.value2 = value2 + super().__init__(*args, **kwargs) + + # Test the singleton parent class + parent1_instance1 = SingletonParent1(value1=8) + assert parent1_instance1.value1 == 8 + parent1_instance2 = SingletonParent1(value1=9) + assert parent1_instance1 is parent1_instance2 + assert parent1_instance1.value1 == 9 + assert parent1_instance2.value1 == 9 + + # Test the singleton child class + child3_instance1 = SingletonChild3() + assert child3_instance1 is not parent1_instance1 + assert child3_instance1 is not parent1_instance2 + # The parent values are INHERITED, but not SYNCED + assert child3_instance1.value1 == 3 + assert child3_instance1.value2 == 'lop' + child3_instance2 = SingletonChild3(value1=-2) + assert child3_instance2 is child3_instance1 + assert child3_instance2 is not parent1_instance1 + assert child3_instance2 is not parent1_instance2 + assert child3_instance1.value1 == -2 + assert child3_instance1.value2 == 'lop' + child3_instance3 = SingletonChild3(value2=4.345) + assert child3_instance3 is child3_instance1 + assert child3_instance3 is child3_instance2 + assert child3_instance3 is not parent1_instance1 + assert child3_instance3 is not parent1_instance2 + assert child3_instance1.value1 == -2 + assert child3_instance1.value2 == 4.345 + child3_instance4 = SingletonChild3(value1='jej', value2='josepHArt') + assert child3_instance4 is child3_instance1 + assert child3_instance4 is child3_instance2 + assert child3_instance4 is child3_instance3 + assert child3_instance4 is not parent1_instance1 + assert child3_instance1.value1 == 'jej' + assert child3_instance1.value2 == 'josepHArt' + + # Test the other singleton child class + child4_instance1 = SingletonChild4() + assert child4_instance1 is not parent1_instance1 + assert child4_instance1 is not parent1_instance2 + assert child4_instance1 is not child3_instance1 + assert child4_instance1 is not child3_instance2 + assert child4_instance1 is not child3_instance3 + assert child4_instance1 is not child3_instance4 + # The parent values are INHERITED, but not SYNCED + assert child4_instance1.value1 == 3 + assert child4_instance1.value2 == 0 + child4_instance2 = SingletonChild4(value1=0.11) + assert child4_instance2 is child4_instance1 + assert child4_instance2 is not parent1_instance1 + assert child4_instance2 is not parent1_instance2 + assert child4_instance2 is not child3_instance1 + assert child4_instance2 is not child3_instance2 + assert child4_instance2 is not child3_instance3 + assert child4_instance2 is not child3_instance4 + assert child4_instance1.value1 == 0.11 + assert child4_instance1.value2 == 0 + child4_instance3 = SingletonChild4(value2=6) + assert child4_instance3 is child4_instance1 + assert child4_instance3 is child4_instance2 + assert child4_instance3 is not parent1_instance1 + assert child4_instance3 is not parent1_instance2 + assert child4_instance3 is not child3_instance1 + assert child4_instance3 is not child3_instance2 + assert child4_instance3 is not child3_instance3 + assert child4_instance3 is not child3_instance4 + assert child4_instance1.value1 == 0.11 + assert child4_instance1.value2 == 6 + child4_instance4 = SingletonChild4(value1='hoho', value2=22) + assert child4_instance4 is child4_instance1 + assert child4_instance4 is child4_instance2 + assert child4_instance4 is child4_instance3 + assert child4_instance4 is not parent1_instance1 + assert child4_instance4 is not parent1_instance2 + assert child4_instance4 is not child3_instance1 + assert child4_instance4 is not child3_instance2 + assert child4_instance4 is not child3_instance3 + assert child4_instance4 is not child3_instance4 + assert child4_instance1.value1 == 'hoho' + assert child4_instance1.value2 == 22 + + # Assert the (singleton) parent is not influenced by the children + assert parent1_instance2 is parent1_instance1 + assert child3_instance1 is not parent1_instance1 + assert child3_instance2 is not parent1_instance1 + assert child3_instance3 is not parent1_instance1 + assert child3_instance4 is not parent1_instance1 + assert child3_instance1 is not parent1_instance2 + assert child3_instance2 is not parent1_instance2 + assert child3_instance3 is not parent1_instance2 + assert child3_instance4 is not parent1_instance2 + assert child4_instance1 is not parent1_instance1 + assert child4_instance2 is not parent1_instance1 + assert child4_instance3 is not parent1_instance1 + assert child4_instance4 is not parent1_instance1 + assert child4_instance1 is not parent1_instance2 + assert child4_instance2 is not parent1_instance2 + assert child4_instance3 is not parent1_instance2 + assert child4_instance4 is not parent1_instance2 + assert parent1_instance1.value1 == 9 + assert parent1_instance2.value1 == 9 + parent1_instance3 = SingletonParent1(value1=23) + assert parent1_instance2 is parent1_instance1 + assert parent1_instance3 is parent1_instance2 + assert child3_instance1 is not parent1_instance1 + assert child3_instance2 is not parent1_instance1 + assert child3_instance3 is not parent1_instance1 + assert child3_instance4 is not parent1_instance1 + assert child3_instance1 is not parent1_instance2 + assert child3_instance2 is not parent1_instance2 + assert child3_instance3 is not parent1_instance2 + assert child3_instance4 is not parent1_instance2 + assert child4_instance1 is not parent1_instance1 + assert child4_instance2 is not parent1_instance1 + assert child4_instance3 is not parent1_instance1 + assert child4_instance4 is not parent1_instance1 + assert child4_instance1 is not parent1_instance2 + assert child4_instance2 is not parent1_instance2 + assert child4_instance3 is not parent1_instance2 + assert child4_instance4 is not parent1_instance2 + assert parent1_instance1.value1 == 23 + assert parent1_instance2.value1 == 23 + assert parent1_instance3.value1 == 23 + assert child3_instance1.value1 == 'jej' + assert child3_instance1.value2 == 'josepHArt' + assert child4_instance1.value1 == 'hoho' + assert child4_instance1.value2 == 22 + + # Now delete all and start fresh, to ensure children can instantiate without parent existing. + SingletonParent1.delete() + assert SingletonParent1._singleton_instance is None + SingletonChild3.delete() + assert SingletonChild3._singleton_instance is None + SingletonChild4.delete() + assert SingletonChild4._singleton_instance is None + + # Test the singleton child class without parent + child3_instance5 = SingletonChild3() + # The parent values are INHERITED, but not SYNCED + assert child3_instance5.value1 == 3 + assert child3_instance5.value2 == 'lop' + child3_instance6 = SingletonChild3(value1=-2) + assert child3_instance6 is child3_instance5 + assert child3_instance5.value1 == -2 + assert child3_instance5.value2 == 'lop' + child3_instance7 = SingletonChild3(value2=4.345) + assert child3_instance7 is child3_instance5 + assert child3_instance7 is child3_instance6 + assert child3_instance5.value1 == -2 + assert child3_instance5.value2 == 4.345 + child3_instance8 = SingletonChild3(value1='jej', value2='josepHArt') + assert child3_instance8 is child3_instance5 + assert child3_instance8 is child3_instance6 + assert child3_instance8 is child3_instance7 + assert child3_instance5.value1 == 'jej' + assert child3_instance5.value2 == 'josepHArt' + + # Test the other singleton child class without parent + child4_instance5 = SingletonChild4() + assert child4_instance5 is not child3_instance5 + assert child4_instance5 is not child3_instance6 + assert child4_instance5 is not child3_instance7 + assert child4_instance5 is not child3_instance8 + # The parent values are INHERITED, but not SYNCED + assert child4_instance5.value1 == 3 + assert child4_instance5.value2 == 0 + child4_instance6 = SingletonChild4(value1=0.11) + assert child4_instance6 is child4_instance5 + assert child4_instance6 is not child3_instance5 + assert child4_instance6 is not child3_instance6 + assert child4_instance6 is not child3_instance7 + assert child4_instance6 is not child3_instance8 + assert child4_instance5.value1 == 0.11 + assert child4_instance5.value2 == 0 + child4_instance7 = SingletonChild4(value2=6) + assert child4_instance7 is child4_instance5 + assert child4_instance7 is child4_instance6 + assert child4_instance7 is not child3_instance5 + assert child4_instance7 is not child3_instance6 + assert child4_instance7 is not child3_instance7 + assert child4_instance7 is not child3_instance8 + assert child4_instance5.value1 == 0.11 + assert child4_instance5.value2 == 6 + child4_instance8 = SingletonChild4(value1='hoho', value2=22) + assert child4_instance8 is child4_instance5 + assert child4_instance8 is child4_instance6 + assert child4_instance8 is child4_instance7 + assert child4_instance8 is not child3_instance5 + assert child4_instance8 is not child3_instance6 + assert child4_instance8 is not child3_instance7 + assert child4_instance8 is not child3_instance8 + assert child4_instance5.value1 == 'hoho' + assert child4_instance5.value2 == 22 + + # Clean up + SingletonParent.delete() + assert SingletonParent._singleton_instance is None + SingletonChild3.delete() + assert SingletonChild3._singleton_instance is None + SingletonChild4.delete() + assert SingletonChild4._singleton_instance is None + + +# This is the same test as above, but with the singleton decorator applied to parent and child +def test_double_singleton_inheritance(): + @singleton + class SingletonParent2: + def __init__(self, value1=7): + print("In SingletonParent2 __init__") + self.value1 = value1 + + @singleton + class SingletonChild5(SingletonParent2): + def __init__(self, *args, value2='lop', **kwargs): + print("In SingletonChild5 __init__") + self.value2 = value2 + super().__init__(*args, **kwargs) + + @singleton + class SingletonChild6(SingletonParent2): + def __init__(self, *args, value2=0, **kwargs): + print("In SingletonChild6 __init__") + self.value2 = value2 + super().__init__(*args, **kwargs) + + # Test the singleton parent class + parent2_instance1 = SingletonParent2(value1=8) + assert parent2_instance1.value1 == 8 + parent2_instance2 = SingletonParent2(value1=9) + assert parent2_instance1 is parent2_instance2 + assert parent2_instance1.value1 == 9 + assert parent2_instance2.value1 == 9 + + # Test the singleton child class + child5_instance1 = SingletonChild5() + assert child5_instance1 is not parent2_instance1 + assert child5_instance1 is not parent2_instance2 + # The parent values are INHERITED, but not SYNCED + assert child5_instance1.value1 == 3 + assert child5_instance1.value2 == 'lop' + child5_instance2 = SingletonChild5(value1=-2) + assert child5_instance2 is child5_instance1 + assert child5_instance2 is not parent2_instance1 + assert child5_instance2 is not parent2_instance2 + assert child5_instance1.value1 == -2 + assert child5_instance1.value2 == 'lop' + child5_instance3 = SingletonChild5(value2=4.345) + assert child5_instance3 is child5_instance1 + assert child5_instance3 is child5_instance2 + assert child5_instance3 is not parent2_instance1 + assert child5_instance3 is not parent2_instance2 + assert child5_instance1.value1 == -2 + assert child5_instance1.value2 == 4.345 + child5_instance4 = SingletonChild5(value1='jej', value2='josepHArt') + assert child5_instance4 is child5_instance1 + assert child5_instance4 is child5_instance2 + assert child5_instance4 is child5_instance3 + assert child5_instance4 is not parent2_instance1 + assert child5_instance1.value1 == 'jej' + assert child5_instance1.value2 == 'josepHArt' + + # Test the other singleton child class + child6_instance1 = SingletonChild6() + assert child6_instance1 is not parent2_instance1 + assert child6_instance1 is not parent2_instance2 + assert child6_instance1 is not child5_instance1 + assert child6_instance1 is not child5_instance2 + assert child6_instance1 is not child5_instance3 + assert child6_instance1 is not child5_instance4 + # The parent values are INHERITED, but not SYNCED + assert child6_instance1.value1 == 3 + assert child6_instance1.value2 == 0 + child6_instance2 = SingletonChild6(value1=0.11) + assert child6_instance2 is child6_instance1 + assert child6_instance2 is not parent2_instance1 + assert child6_instance2 is not parent2_instance2 + assert child6_instance2 is not child5_instance1 + assert child6_instance2 is not child5_instance2 + assert child6_instance2 is not child5_instance3 + assert child6_instance2 is not child5_instance4 + assert child6_instance1.value1 == 0.11 + assert child6_instance1.value2 == 0 + child6_instance3 = SingletonChild6(value2=6) + assert child6_instance3 is child6_instance1 + assert child6_instance3 is child6_instance2 + assert child6_instance3 is not parent2_instance1 + assert child6_instance3 is not parent2_instance2 + assert child6_instance3 is not child5_instance1 + assert child6_instance3 is not child5_instance2 + assert child6_instance3 is not child5_instance3 + assert child6_instance3 is not child5_instance4 + assert child6_instance1.value1 == 0.11 + assert child6_instance1.value2 == 6 + child6_instance4 = SingletonChild6(value1='hoho', value2=22) + assert child6_instance4 is child6_instance1 + assert child6_instance4 is child6_instance2 + assert child6_instance4 is child6_instance3 + assert child6_instance4 is not parent2_instance1 + assert child6_instance4 is not parent2_instance2 + assert child6_instance4 is not child5_instance1 + assert child6_instance4 is not child5_instance2 + assert child6_instance4 is not child5_instance3 + assert child6_instance4 is not child5_instance4 + assert child6_instance1.value1 == 'hoho' + assert child6_instance1.value2 == 22 + + # Assert the (singleton) parent is not influenced by the children + assert parent2_instance2 is parent2_instance1 + assert child5_instance1 is not parent2_instance1 + assert child5_instance2 is not parent2_instance1 + assert child5_instance3 is not parent2_instance1 + assert child5_instance4 is not parent2_instance1 + assert child5_instance1 is not parent2_instance2 + assert child5_instance2 is not parent2_instance2 + assert child5_instance3 is not parent2_instance2 + assert child5_instance4 is not parent2_instance2 + assert child6_instance1 is not parent2_instance1 + assert child6_instance2 is not parent2_instance1 + assert child6_instance3 is not parent2_instance1 + assert child6_instance4 is not parent2_instance1 + assert child6_instance1 is not parent2_instance2 + assert child6_instance2 is not parent2_instance2 + assert child6_instance3 is not parent2_instance2 + assert child6_instance4 is not parent2_instance2 + assert parent2_instance1.value1 == 9 + assert parent2_instance2.value1 == 9 + parent2_instance3 = SingletonParent2(value1=23) + assert parent2_instance2 is parent2_instance1 + assert parent2_instance3 is parent2_instance2 + assert child5_instance1 is not parent2_instance1 + assert child5_instance2 is not parent2_instance1 + assert child5_instance3 is not parent2_instance1 + assert child5_instance4 is not parent2_instance1 + assert child5_instance1 is not parent2_instance2 + assert child5_instance2 is not parent2_instance2 + assert child5_instance3 is not parent2_instance2 + assert child5_instance4 is not parent2_instance2 + assert child6_instance1 is not parent2_instance1 + assert child6_instance2 is not parent2_instance1 + assert child6_instance3 is not parent2_instance1 + assert child6_instance4 is not parent2_instance1 + assert child6_instance1 is not parent2_instance2 + assert child6_instance2 is not parent2_instance2 + assert child6_instance3 is not parent2_instance2 + assert child6_instance4 is not parent2_instance2 + assert parent2_instance1.value1 == 23 + assert parent2_instance2.value1 == 23 + assert parent2_instance3.value1 == 23 + assert child5_instance1.value1 == 'jej' + assert child5_instance1.value2 == 'josepHArt' + assert child6_instance1.value1 == 'hoho' + assert child6_instance1.value2 == 22 + + # Now delete all and start fresh, to ensure children can instantiate without parent existing. + SingletonParent2.delete() + assert SingletonParent2._singleton_instance is None + SingletonChild5.delete() + assert SingletonChild5._singleton_instance is None + SingletonChild6.delete() + assert SingletonChild6._singleton_instance is None + + # Test the singleton child class without parent + child5_instance5 = SingletonChild5() + # The parent values are INHERITED, but not SYNCED + assert child5_instance5.value1 == 3 + assert child5_instance5.value2 == 'lop' + child5_instance6 = SingletonChild5(value1=-2) + assert child5_instance6 is child5_instance5 + assert child5_instance5.value1 == -2 + assert child5_instance5.value2 == 'lop' + child5_instance7 = SingletonChild5(value2=4.345) + assert child5_instance7 is child5_instance5 + assert child5_instance7 is child5_instance6 + assert child5_instance5.value1 == -2 + assert child5_instance5.value2 == 4.345 + child5_instance8 = SingletonChild5(value1='jej', value2='josepHArt') + assert child5_instance8 is child5_instance5 + assert child5_instance8 is child5_instance6 + assert child5_instance8 is child5_instance7 + assert child5_instance5.value1 == 'jej' + assert child5_instance5.value2 == 'josepHArt' + + # Test the other singleton child class without parent + child6_instance5 = SingletonChild6() + assert child6_instance5 is not child5_instance5 + assert child6_instance5 is not child5_instance6 + assert child6_instance5 is not child5_instance7 + assert child6_instance5 is not child5_instance8 + # The parent values are INHERITED, but not SYNCED + assert child6_instance5.value1 == 3 + assert child6_instance5.value2 == 0 + child6_instance6 = SingletonChild6(value1=0.11) + assert child6_instance6 is child6_instance5 + assert child6_instance6 is not child5_instance5 + assert child6_instance6 is not child5_instance6 + assert child6_instance6 is not child5_instance7 + assert child6_instance6 is not child5_instance8 + assert child6_instance5.value1 == 0.11 + assert child6_instance5.value2 == 0 + child6_instance7 = SingletonChild6(value2=6) + assert child6_instance7 is child6_instance5 + assert child6_instance7 is child6_instance6 + assert child6_instance7 is not child5_instance5 + assert child6_instance7 is not child5_instance6 + assert child6_instance7 is not child5_instance7 + assert child6_instance7 is not child5_instance8 + assert child6_instance5.value1 == 0.11 + assert child6_instance5.value2 == 6 + child6_instance8 = SingletonChild6(value1='hoho', value2=22) + assert child6_instance8 is child6_instance5 + assert child6_instance8 is child6_instance6 + assert child6_instance8 is child6_instance7 + assert child6_instance8 is not child5_instance5 + assert child6_instance8 is not child5_instance6 + assert child6_instance8 is not child5_instance7 + assert child6_instance8 is not child5_instance8 + assert child6_instance5.value1 == 'hoho' + assert child6_instance5.value2 == 22 + + # Clean up + SingletonChild5.delete() + assert SingletonChild5._singleton_instance is None + SingletonChild6.delete() + assert SingletonChild6._singleton_instance is None + + +def test_get_self(): + @singleton + class SingletonClass3: + def __init__(self, value=19): + self.value = value + + # Initialise with default value + instance = SingletonClass3() + instance.value = 19 + + # Get self with default value + self1 = SingletonClass3.get_self() + assert self1 is instance + assert id(self1) == id(instance) + assert self1.value == 19 + assert instance.value == 19 + + # Get self with specific value + self2 = SingletonClass3.get_self(value=11) + assert self2 is instance + assert self2 is self1 + assert id(self2) == id(instance) + assert id(self2) == id(self1) + assert instance.value == 11 + assert self1.value == 11 + assert self2.value == 11 + + # Get self with non-existing attribute + self3 = SingletonClass3.get_self(non_existing_attribute=13) + assert self3 is instance + assert self3 is self1 + assert self3 is self2 + assert id(self3) == id(instance) + assert id(self3) == id(self1) + assert id(self3) == id(self2) + assert instance.value == 11 + assert self1.value == 11 + assert self2.value == 11 + assert self3.value == 11 + assert not hasattr(SingletonClass3, 'non_existing_attribute') + assert not hasattr(instance, 'non_existing_attribute') + assert not hasattr(self1, 'non_existing_attribute') + assert not hasattr(self2, 'non_existing_attribute') + assert not hasattr(self3, 'non_existing_attribute') + + # Get self with specific value and non-existing attribute + self4 = SingletonClass3.get_self(value=12, non_existing_attribute=13) + assert self4 is instance + assert self4 is self1 + assert self4 is self2 + assert self4 is self3 + assert id(self4) == id(instance) + assert id(self4) == id(self1) + assert id(self4) == id(self2) + assert id(self4) == id(self3) + assert instance.value == 12 + assert self1.value == 12 + assert self2.value == 12 + assert self3.value == 12 + assert self4.value == 12 + assert not hasattr(SingletonClass3, 'non_existing_attribute') + assert not hasattr(instance, 'non_existing_attribute') + assert not hasattr(self1, 'non_existing_attribute') + assert not hasattr(self2, 'non_existing_attribute') + assert not hasattr(self3, 'non_existing_attribute') + assert not hasattr(self4, 'non_existing_attribute') + + # Remove the singleton + SingletonClass3.delete() + + # Initialise with get self with default value + self5 = SingletonClass3.get_self() + assert self5 is not instance + assert self5 is not self1 + assert self5 is not self2 + assert self5 is not self3 + assert self5 is not self4 + assert id(self5) != id(instance) + assert id(self5) != id(self1) + assert id(self5) != id(self2) + assert id(self5) != id(self3) + assert id(self5) != id(self4) + assert self5.value == 19 + + # Remove the singleton + SingletonClass3.delete() + + # Initialise with get self with specific value + self6 = SingletonClass3.get_self(value=-3) + assert self6 is not instance + assert self6 is not self1 + assert self6 is not self2 + assert self6 is not self3 + assert self6 is not self4 + assert self6 is not self5 + assert id(self6) != id(instance) + assert id(self6) != id(self1) + assert id(self6) != id(self2) + assert id(self6) != id(self3) + assert id(self6) != id(self4) + assert id(self6) != id(self5) + assert self6.value == -3 + + +def test_get_self_with_inheritance(): + @singleton + class SingletonClass4: + def __init__(self, value=0.2): + self.value = value + + +def test_singleton_with_custom_new_and_init(): + @singleton + class SingletonClass5: + def __init__(self, value='YASSS'): + self.value = value + + +def test_singleton_with_custom_new_and_init_with_inheritance(): + pass + From e8e97405203e885cd074de01e5f6b74796e86e63 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Thu, 16 Jan 2025 02:23:23 +0100 Subject: [PATCH 09/26] Fixed important bug: 'cls' in def singleton should not be used in the monkey-patch functions. Inheritance still WIP --- xaux/tools/class_tools.py | 87 +++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/xaux/tools/class_tools.py b/xaux/tools/class_tools.py index 062a792..c688ae8 100644 --- a/xaux/tools/class_tools.py +++ b/xaux/tools/class_tools.py @@ -10,72 +10,97 @@ def singleton(cls, allow_underscore_vars_in_init=False): - # Monkey-patch the __new__ method to create a singleton - original_new = cls.__dict__.get('__new__', None) + # The singleton will be represented by a single instance cls._singleton_instance = None - def singleton_new(cls, *args, **kwargs): - if cls._singleton_instance is None: # Check if the class is already initialised - cls._singleton_instance = (original_new(cls, *args, **kwargs) \ + + # Monkey-patch __new__ to create a singleton + original_new = cls.__dict__.get('__new__', None) + def singleton_new(this_cls, *args, **kwargs): + # print(f"In singleton_new: {this_cls}") + # If the singleton instance does not exist, create it + if this_cls._singleton_instance is None: + this_cls._singleton_instance = (original_new(this_cls, *args, **kwargs) \ if original_new \ - else super(cls, cls).__new__(cls)) - cls._singleton_instance._initialised = False - cls._singleton_instance._valid = True - return cls._singleton_instance + else super(this_cls, this_cls).__new__(this_cls)) + this_cls._singleton_instance._initialised = False + this_cls._singleton_instance._valid = True + return this_cls._singleton_instance cls.__new__ = singleton_new - # Monkey-patch the __init__ method to set the singleton fields + # Monkey-patch __init__ to set the singleton fields original_init = cls.__dict__.get('__init__', None) if original_init: if count_required_arguments(original_init) > 1: raise ValueError(f"Cannot create a singleton with an __init__ method that " + "has more than one required argument (only 'self' is allowed)!") def singleton_init(self, *args, **kwargs): + # print(f"In singleton_init: {self.__class__}") + # Validate kwargs kwargs.pop('_initialised', None) + for kk, vv in kwargs.items(): + if not allow_underscore_vars_in_init and kk.startswith('_'): + raise ValueError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " + + "Use the appropriate setter method instead. However, if you " + + "really want to be able to set this attribute in the " + + "constructor, use 'allow_underscore_vars_in_init=True' " + + "in the singleton decorator.") + # Get the current class type (this is not 'cls' as it might be a subclass). Also note that + # self.__class__ would get into an infinite loop because of the __getattribute__ method + this_cls = type(self) + # Initialise the singleton if it has not been initialised yet if not self._initialised: if original_init: original_init(self, *args, **kwargs) else: - super(cls, self).__init__(*args, **kwargs) + super(this_cls, self).__init__(*args, **kwargs) self._initialised = True + # Set the attributes; only attributes defined in the class, custom init, or properties + # are allowed for kk, vv in kwargs.items(): - if not allow_underscore_vars_in_init and kk.startswith('_'): - raise ValueError(f"Cannot set private attribute {kk} for {cls.__name__}!") - if not hasattr(self, kk) and not hasattr(cls, kk): - raise ValueError(f"Invalid attribute {kk} for {cls.__name__}!") + if not hasattr(self, kk) and not hasattr(this_cls, kk): + raise ValueError(f"Invalid attribute {kk} for {this_cls.__name__}!") setattr(self, kk, vv) cls.__init__ = singleton_init - # Monkey-patch the __getattribute__ method to assert the instance belongs to the current singleton + # Monkey-patch __getattribute__ to assert the instance belongs to the current singleton original_getattribute = cls.__dict__.get('__getattribute__', None) def singleton_getattribute(self, name): - if not super(cls, self).__getattribute__('_valid'): - raise RuntimeError(f"This instance of the singleton {cls.__name__} has been invalidated!") + # Get the current class (this is not cls as it might be a subclass). Also note that + # self.__class__ will get into an infinite loop because of the __getattribute__ method + this_cls = type(self) + if not super(this_cls, self).__getattribute__('_valid'): + raise RuntimeError(f"This instance of the singleton {this_cls.__name__} has been " + + "invalidated!") if original_getattribute: return original_getattribute(self, name) else: - return super(cls, self).__getattribute__(name) + return super(this_cls, self).__getattribute__(name) cls.__getattribute__ = singleton_getattribute - # Define the get_self method + # Define the get_self method. This method is more relaxed than the constructor, as it allows + # setting any attribute, even if it is not defined in the class (it will then just be ignored). + # This is useful for kwargs filtering in getters or specific functions. @classmethod - def get_self(cls, **kwargs): - # Need to initialise class in case the instance does not yet exist - # (to recognise get the allowed fields) - cls() + def get_self(this_cls, **kwargs): + # Need to initialise in case the instance does not yet exist + # (to recognise the allowed fields) + this_cls() filtered_kwargs = {key: value for key, value in kwargs.items() - if hasattr(cls, key) or hasattr(cls._singleton_instance, key)} + if hasattr(this_cls, key) \ + or hasattr(this_cls._singleton_instance, key)} if not allow_underscore_vars_in_init: filtered_kwargs = {key: value for key, value in filtered_kwargs.items() if not key.startswith('_')} - return cls(**filtered_kwargs) + return this_cls(**filtered_kwargs) cls.get_self = get_self - # Define the delete method + # Define the delete method to complete remove a singleton @classmethod - def delete(cls): - if cls._singleton_instance is not None: - cls._singleton_instance._valid = False # Invalidate existing instances - cls._singleton_instance = None + def delete(this_cls): + if this_cls._singleton_instance is not None: + # Invalidate (pointers to) existing instances! + this_cls._singleton_instance._valid = False + this_cls._singleton_instance = None cls.delete = delete return cls From 02e76b6f5131cb487fa06e4cf4500f17f391c28a Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Thu, 16 Jan 2025 04:03:24 +0100 Subject: [PATCH 10/26] Much closer to final result but still WIP for inheritance --- tests/test_singleton.py | 74 +++++++------ xaux/tools/class_tools.py | 218 ++++++++++++++++++++++---------------- 2 files changed, 164 insertions(+), 128 deletions(-) diff --git a/tests/test_singleton.py b/tests/test_singleton.py index 7fd0c50..ca9d14f 100644 --- a/tests/test_singleton.py +++ b/tests/test_singleton.py @@ -38,9 +38,9 @@ def __init__(self, value=3): self.value = value # Initialise with default value - assert SingletonClass1._singleton_instance is None + assert not hasattr(SingletonClass1, '_singleton_instance') instance1 = SingletonClass1() - assert SingletonClass1._singleton_instance is not None + assert hasattr(SingletonClass1, '_singleton_instance') assert instance1.value == 3 # Initialise with specific value @@ -66,7 +66,7 @@ def __init__(self, value=3): # Remove the singleton SingletonClass1.delete() - assert SingletonClass1._singleton_instance is None + assert not hasattr(SingletonClass1, '_singleton_instance') with pytest.raises(RuntimeError, match="This instance of the singleton SingletonClass1 " + "has been invalidated!"): instance1.value @@ -79,7 +79,7 @@ def __init__(self, value=3): # First initialisation with specific value instance4 = SingletonClass1(value=8) - assert SingletonClass1._singleton_instance is not None + assert hasattr(SingletonClass1, '_singleton_instance') assert instance4.value == 8 assert instance1 is not instance4 assert instance2 is not instance4 @@ -90,11 +90,11 @@ def __init__(self, value=3): # Clean up SingletonClass1.delete() - assert SingletonClass1._singleton_instance is None + assert not hasattr(SingletonClass1, '_singleton_instance') # Test double deletion SingletonClass1.delete() - assert SingletonClass1._singleton_instance is None + assert not hasattr(SingletonClass1, '_singleton_instance') def test_singleton_structure(): @@ -237,9 +237,9 @@ def __init__(self, *args, value2=-13, **kwargs): # Clean up SingletonChild1.delete() - assert SingletonChild1._singleton_instance is None + assert not hasattr(SingletonChild1, '_singleton_instance') SingletonChild2.delete() - assert SingletonChild2._singleton_instance is None + assert not hasattr(SingletonChild2, '_singleton_instance') def test_singleton_inheritance(): @@ -391,11 +391,11 @@ def __init__(self, *args, value2=0, **kwargs): # Now delete all and start fresh, to ensure children can instantiate without parent existing. SingletonParent1.delete() - assert SingletonParent1._singleton_instance is None + assert not hasattr(SingletonParent1, '_singleton_instance') SingletonChild3.delete() - assert SingletonChild3._singleton_instance is None + assert not hasattr(SingletonChild3, '_singleton_instance') SingletonChild4.delete() - assert SingletonChild4._singleton_instance is None + assert not hasattr(SingletonChild4, '_singleton_instance') # Test the singleton child class without parent child3_instance5 = SingletonChild3() @@ -456,12 +456,10 @@ def __init__(self, *args, value2=0, **kwargs): assert child4_instance5.value2 == 22 # Clean up - SingletonParent.delete() - assert SingletonParent._singleton_instance is None SingletonChild3.delete() - assert SingletonChild3._singleton_instance is None + assert not hasattr(SingletonChild3, '_singleton_instance') SingletonChild4.delete() - assert SingletonChild4._singleton_instance is None + assert not hasattr(SingletonChild4, '_singleton_instance') # This is the same test as above, but with the singleton decorator applied to parent and child @@ -616,11 +614,11 @@ def __init__(self, *args, value2=0, **kwargs): # Now delete all and start fresh, to ensure children can instantiate without parent existing. SingletonParent2.delete() - assert SingletonParent2._singleton_instance is None + assert not hasattr(SingletonParent2, '_singleton_instance') SingletonChild5.delete() - assert SingletonChild5._singleton_instance is None + assert not hasattr(SingletonChild5, '_singleton_instance') SingletonChild6.delete() - assert SingletonChild6._singleton_instance is None + assert not hasattr(SingletonChild6, '_singleton_instance') # Test the singleton child class without parent child5_instance5 = SingletonChild5() @@ -682,30 +680,38 @@ def __init__(self, *args, value2=0, **kwargs): # Clean up SingletonChild5.delete() - assert SingletonChild5._singleton_instance is None + assert not hasattr(SingletonChild5, '_singleton_instance') SingletonChild6.delete() - assert SingletonChild6._singleton_instance is None + assert not hasattr(SingletonChild6, '_singleton_instance') + + +def test_allow_underscore(): + @singleton(allow_underscore_vars_in_init=True) + class SingletonClass3: + def __init__(self, _value1=7.3, value2='hello'): + self._value1 = _value1 + self.value2 = value2 def test_get_self(): @singleton - class SingletonClass3: + class SingletonClass4: def __init__(self, value=19): self.value = value # Initialise with default value - instance = SingletonClass3() + instance = SingletonClass4() instance.value = 19 # Get self with default value - self1 = SingletonClass3.get_self() + self1 = SingletonClass4.get_self() assert self1 is instance assert id(self1) == id(instance) assert self1.value == 19 assert instance.value == 19 # Get self with specific value - self2 = SingletonClass3.get_self(value=11) + self2 = SingletonClass4.get_self(value=11) assert self2 is instance assert self2 is self1 assert id(self2) == id(instance) @@ -715,7 +721,7 @@ def __init__(self, value=19): assert self2.value == 11 # Get self with non-existing attribute - self3 = SingletonClass3.get_self(non_existing_attribute=13) + self3 = SingletonClass4.get_self(non_existing_attribute=13) assert self3 is instance assert self3 is self1 assert self3 is self2 @@ -726,14 +732,14 @@ def __init__(self, value=19): assert self1.value == 11 assert self2.value == 11 assert self3.value == 11 - assert not hasattr(SingletonClass3, 'non_existing_attribute') + assert not hasattr(SingletonClass4, 'non_existing_attribute') assert not hasattr(instance, 'non_existing_attribute') assert not hasattr(self1, 'non_existing_attribute') assert not hasattr(self2, 'non_existing_attribute') assert not hasattr(self3, 'non_existing_attribute') # Get self with specific value and non-existing attribute - self4 = SingletonClass3.get_self(value=12, non_existing_attribute=13) + self4 = SingletonClass4.get_self(value=12, non_existing_attribute=13) assert self4 is instance assert self4 is self1 assert self4 is self2 @@ -747,7 +753,7 @@ def __init__(self, value=19): assert self2.value == 12 assert self3.value == 12 assert self4.value == 12 - assert not hasattr(SingletonClass3, 'non_existing_attribute') + assert not hasattr(SingletonClass4, 'non_existing_attribute') assert not hasattr(instance, 'non_existing_attribute') assert not hasattr(self1, 'non_existing_attribute') assert not hasattr(self2, 'non_existing_attribute') @@ -755,10 +761,10 @@ def __init__(self, value=19): assert not hasattr(self4, 'non_existing_attribute') # Remove the singleton - SingletonClass3.delete() + SingletonClass4.delete() # Initialise with get self with default value - self5 = SingletonClass3.get_self() + self5 = SingletonClass4.get_self() assert self5 is not instance assert self5 is not self1 assert self5 is not self2 @@ -772,10 +778,10 @@ def __init__(self, value=19): assert self5.value == 19 # Remove the singleton - SingletonClass3.delete() + SingletonClass4.delete() # Initialise with get self with specific value - self6 = SingletonClass3.get_self(value=-3) + self6 = SingletonClass4.get_self(value=-3) assert self6 is not instance assert self6 is not self1 assert self6 is not self2 @@ -793,14 +799,14 @@ def __init__(self, value=19): def test_get_self_with_inheritance(): @singleton - class SingletonClass4: + class SingletonClass5: def __init__(self, value=0.2): self.value = value def test_singleton_with_custom_new_and_init(): @singleton - class SingletonClass5: + class SingletonClass6: def __init__(self, value='YASSS'): self.value = value diff --git a/xaux/tools/class_tools.py b/xaux/tools/class_tools.py index c688ae8..c077e73 100644 --- a/xaux/tools/class_tools.py +++ b/xaux/tools/class_tools.py @@ -9,101 +9,131 @@ from .function_tools import count_required_arguments -def singleton(cls, allow_underscore_vars_in_init=False): - # The singleton will be represented by a single instance - cls._singleton_instance = None - - # Monkey-patch __new__ to create a singleton - original_new = cls.__dict__.get('__new__', None) - def singleton_new(this_cls, *args, **kwargs): - # print(f"In singleton_new: {this_cls}") - # If the singleton instance does not exist, create it - if this_cls._singleton_instance is None: - this_cls._singleton_instance = (original_new(this_cls, *args, **kwargs) \ - if original_new \ - else super(this_cls, this_cls).__new__(this_cls)) - this_cls._singleton_instance._initialised = False - this_cls._singleton_instance._valid = True - return this_cls._singleton_instance - cls.__new__ = singleton_new - - # Monkey-patch __init__ to set the singleton fields - original_init = cls.__dict__.get('__init__', None) - if original_init: - if count_required_arguments(original_init) > 1: - raise ValueError(f"Cannot create a singleton with an __init__ method that " - + "has more than one required argument (only 'self' is allowed)!") - def singleton_init(self, *args, **kwargs): - # print(f"In singleton_init: {self.__class__}") - # Validate kwargs - kwargs.pop('_initialised', None) - for kk, vv in kwargs.items(): - if not allow_underscore_vars_in_init and kk.startswith('_'): - raise ValueError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " - + "Use the appropriate setter method instead. However, if you " - + "really want to be able to set this attribute in the " - + "constructor, use 'allow_underscore_vars_in_init=True' " - + "in the singleton decorator.") - # Get the current class type (this is not 'cls' as it might be a subclass). Also note that - # self.__class__ would get into an infinite loop because of the __getattribute__ method - this_cls = type(self) - # Initialise the singleton if it has not been initialised yet - if not self._initialised: - if original_init: - original_init(self, *args, **kwargs) +# Singleton decorator. +# This decorator will make a class a singleton, i.e. it will only allow one instance, +# and will return the same instance every time it is called. The singleton can be +# reset by calling the delete method of the class, which will invalidate any existing +# instances. Each re-initialisation of the singleton will keep the class attributes, +# and for this reason, the __init__ method should not have any required arguments. +# This is asserted at the class creation time. Furthermore, by default the singleton +# will not allow setting private attributes in the constructor, but this can be +# overridden by setting allow_underscore_vars_in_init=True in the decorator. +# This is fully compatible with inheritance, and each child of a singleton class will +# be its own singleton. +# Lastly, the decorator provides a get_self method, which is a class method that is +# more relaxed than the constructor, as it allows passing any kwargs, even if they +# aren't attributes for the singleton (these will then just be ignored). This is +# useful for kwargs filtering in getters or specific functions. +# +# Caveat I in the implementation: whenever any monkey-patched method is called, the +# super method should be called on the original singleton class (not the current class), +# to avoid infinite loops (it would just call the same method again). +# Caveat II in the implementation: When we need to get the current class of an instance, +# we should use type(self) instead of self.__class__, as the latter will get into an +# infinite loop because of the __getattribute__ method. + + +def singleton(_cls=None, *, allow_underscore_vars_in_init=False): + def decorator_singleton(cls): + # Monkey-patch __new__ to create a singleton + original_new = cls.__dict__.get('__new__', None) + @functools.wraps(cls) + def singleton_new(this_cls, *args, **kwargs): + print(f"In singleton_new: {this_cls}") + # If the singleton instance does not exist, create it + if '_singleton_instance' not in this_cls.__dict__: + if original_new: + inst = original_new(cls_to_call, *args, **kwargs) + else: + try: + inst = super(cls, cls).__new__(this_cls, *args, **kwargs) + except TypeError: + inst = super(cls, cls).__new__(this_cls) + print(inst) + inst._initialised = False + inst._valid = True + this_cls._singleton_instance = inst + return this_cls._singleton_instance + cls.__new__ = singleton_new + + # Monkey-patch __init__ to set the singleton fields + original_init = cls.__dict__.get('__init__', None) + if original_init: + if count_required_arguments(original_init) > 1: + raise ValueError(f"Cannot create a singleton with an __init__ method that " + + "has more than one required argument (only 'self' is allowed)!") + @functools.wraps(cls) + def singleton_init(self, *args, **kwargs): + this_cls = type(self) + print(f"In singleton_init: {this_cls}") + # Validate kwargs + kwargs.pop('_initialised', None) + for kk, vv in kwargs.items(): + if not allow_underscore_vars_in_init and kk.startswith('_'): + raise ValueError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " + + "Use the appropriate setter method instead. However, if you " + + "really want to be able to set this attribute in the " + + "constructor, use 'allow_underscore_vars_in_init=True' " + + "in the singleton decorator.") + # Initialise the singleton if it has not been initialised yet + if not self._initialised: + if original_init: + original_init(self, *args, **kwargs) + else: + super(cls, self).__init__(*args, **kwargs) + self._initialised = True + # Set the attributes; only attributes defined in the class, custom init, or properties + # are allowed + for kk, vv in kwargs.items(): + if not hasattr(self, kk) and not hasattr(this_cls, kk): + raise ValueError(f"Invalid attribute {kk} for {this_cls.__name__}!") + setattr(self, kk, vv) + cls.__init__ = singleton_init + + # Monkey-patch __getattribute__ to assert the instance belongs to the current singleton + original_getattribute = cls.__dict__.get('__getattribute__', None) + @functools.wraps(cls) + def singleton_getattribute(self, name): + this_cls = type(self) + if not hasattr(this_cls, '_singleton_instance') or not super(cls, self).__getattribute__('_valid'): + raise RuntimeError(f"This instance of the singleton {this_cls.__name__} has been " + + "invalidated!") + if original_getattribute: + return original_getattribute(self, name) else: - super(this_cls, self).__init__(*args, **kwargs) - self._initialised = True - # Set the attributes; only attributes defined in the class, custom init, or properties - # are allowed - for kk, vv in kwargs.items(): - if not hasattr(self, kk) and not hasattr(this_cls, kk): - raise ValueError(f"Invalid attribute {kk} for {this_cls.__name__}!") - setattr(self, kk, vv) - cls.__init__ = singleton_init - - # Monkey-patch __getattribute__ to assert the instance belongs to the current singleton - original_getattribute = cls.__dict__.get('__getattribute__', None) - def singleton_getattribute(self, name): - # Get the current class (this is not cls as it might be a subclass). Also note that - # self.__class__ will get into an infinite loop because of the __getattribute__ method - this_cls = type(self) - if not super(this_cls, self).__getattribute__('_valid'): - raise RuntimeError(f"This instance of the singleton {this_cls.__name__} has been " - + "invalidated!") - if original_getattribute: - return original_getattribute(self, name) - else: - return super(this_cls, self).__getattribute__(name) - cls.__getattribute__ = singleton_getattribute - - # Define the get_self method. This method is more relaxed than the constructor, as it allows - # setting any attribute, even if it is not defined in the class (it will then just be ignored). - # This is useful for kwargs filtering in getters or specific functions. - @classmethod - def get_self(this_cls, **kwargs): - # Need to initialise in case the instance does not yet exist - # (to recognise the allowed fields) - this_cls() - filtered_kwargs = {key: value for key, value in kwargs.items() - if hasattr(this_cls, key) \ - or hasattr(this_cls._singleton_instance, key)} - if not allow_underscore_vars_in_init: - filtered_kwargs = {key: value for key, value in filtered_kwargs.items() - if not key.startswith('_')} - return this_cls(**filtered_kwargs) - cls.get_self = get_self - - # Define the delete method to complete remove a singleton - @classmethod - def delete(this_cls): - if this_cls._singleton_instance is not None: - # Invalidate (pointers to) existing instances! - this_cls._singleton_instance._valid = False - this_cls._singleton_instance = None - cls.delete = delete - - return cls + return super(cls, self).__getattribute__(name) + cls.__getattribute__ = singleton_getattribute + + @classmethod + @functools.wraps(cls) + def get_self(this_cls, **kwargs): + # Need to initialise in case the instance does not yet exist + # (to recognise the allowed fields) + this_cls() + filtered_kwargs = {key: value for key, value in kwargs.items() + if hasattr(this_cls, key) \ + or hasattr(this_cls._singleton_instance, key)} + if not allow_underscore_vars_in_init: + filtered_kwargs = {key: value for key, value in filtered_kwargs.items() + if not key.startswith('_')} + return this_cls(**filtered_kwargs) + cls.get_self = get_self + + @classmethod + @functools.wraps(cls) + def delete(this_cls): + if hasattr(this_cls, '_singleton_instance'): + # Invalidate (pointers to) existing instances! + this_cls._singleton_instance._valid = False + del this_cls._singleton_instance + cls.delete = delete + + return cls + + if _cls is None: + return decorator_singleton + else: + return decorator_singleton(_cls) class ClassPropertyMeta(type): From e3e35ada236cb7e23efe728e172fbd43260b37ec Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 17 Jan 2025 20:13:32 +0100 Subject: [PATCH 11/26] Singleton works --- xaux/tools/class_tools.py | 190 +++++++++++++------------------------- 1 file changed, 64 insertions(+), 126 deletions(-) diff --git a/xaux/tools/class_tools.py b/xaux/tools/class_tools.py index c077e73..82a8f6c 100644 --- a/xaux/tools/class_tools.py +++ b/xaux/tools/class_tools.py @@ -1,6 +1,6 @@ # copyright ############################### # -# This file is part of the Xcoll Package. # -# Copyright (c) CERN, 2024. # +# This file is part of the Xaux package. # +# Copyright (c) CERN, 2025. # # ######################################### # import functools @@ -9,47 +9,49 @@ from .function_tools import count_required_arguments -# Singleton decorator. -# This decorator will make a class a singleton, i.e. it will only allow one instance, -# and will return the same instance every time it is called. The singleton can be -# reset by calling the delete method of the class, which will invalidate any existing -# instances. Each re-initialisation of the singleton will keep the class attributes, -# and for this reason, the __init__ method should not have any required arguments. -# This is asserted at the class creation time. Furthermore, by default the singleton -# will not allow setting private attributes in the constructor, but this can be -# overridden by setting allow_underscore_vars_in_init=True in the decorator. -# This is fully compatible with inheritance, and each child of a singleton class will -# be its own singleton. -# Lastly, the decorator provides a get_self method, which is a class method that is -# more relaxed than the constructor, as it allows passing any kwargs, even if they -# aren't attributes for the singleton (these will then just be ignored). This is -# useful for kwargs filtering in getters or specific functions. -# -# Caveat I in the implementation: whenever any monkey-patched method is called, the -# super method should be called on the original singleton class (not the current class), -# to avoid infinite loops (it would just call the same method again). -# Caveat II in the implementation: When we need to get the current class of an instance, -# we should use type(self) instead of self.__class__, as the latter will get into an -# infinite loop because of the __getattribute__ method. - - -def singleton(_cls=None, *, allow_underscore_vars_in_init=False): +# TODO: allow_underscore_vars_in_init=False is not very robust. Do we need it for ClassProperty? + +def singleton(_cls=None, *, allow_underscore_vars_in_init=True): + """Singleton decorator. + This decorator will redefine a class such that only one instance exists and the same + instance is returned every time the class is instantiated. + - Each re-initialisation of the singleton will keep the class attributes, and for this + reason, the __init__ method should not have any required arguments. This is asserted + at the class creation time. + - By default, the singleton will not allow setting private attributes in the constructor, + but this can be overridden by setting 'allow_underscore_vars_in_init=True'. + - This decorator is fully compatible with inheritance, and each child of a singleton + class will be its own singleton. + - The singleton can be reset by calling the 'delete()' method of the class, which will + invalidate any existing instances. + - The decorator provides a get_self method, which is a class method that is more relaxed + than the constructor, as it allows passing any kwargs even if they aren't attributes + for the singleton (these will then just be ignored). This is useful for kwargs + filtering in getters or specific functions. + """ + # Caveat I: whenever any monkey-patched method is called, the super method should be + # called on the original singleton class (not the current class), to avoid infinite + # loops (it would just call the same method again). + # Caveat II: When we need to get the current class of an instance, + # we should use type(self) instead of self.__class__, as the latter will get into an + # infinite loop because of the __getattribute__ method. + + # Internal decorator definition to used without arguments def decorator_singleton(cls): # Monkey-patch __new__ to create a singleton original_new = cls.__dict__.get('__new__', None) - @functools.wraps(cls) + @functools.wraps(cls.__new__) def singleton_new(this_cls, *args, **kwargs): - print(f"In singleton_new: {this_cls}") # If the singleton instance does not exist, create it if '_singleton_instance' not in this_cls.__dict__: if original_new: - inst = original_new(cls_to_call, *args, **kwargs) + # This NEEDS to call 'this_cls' instead of 'cls' to avoid always spawning a cls instance + inst = original_new(this_cls, *args, **kwargs) else: try: inst = super(cls, cls).__new__(this_cls, *args, **kwargs) except TypeError: inst = super(cls, cls).__new__(this_cls) - print(inst) inst._initialised = False inst._valid = True this_cls._singleton_instance = inst @@ -61,14 +63,13 @@ def singleton_new(this_cls, *args, **kwargs): if original_init: if count_required_arguments(original_init) > 1: raise ValueError(f"Cannot create a singleton with an __init__ method that " - + "has more than one required argument (only 'self' is allowed)!") - @functools.wraps(cls) + + "has more than one required argument (only 'self' is allowed)!") + @functools.wraps(cls.__init__) def singleton_init(self, *args, **kwargs): this_cls = type(self) - print(f"In singleton_init: {this_cls}") # Validate kwargs kwargs.pop('_initialised', None) - for kk, vv in kwargs.items(): + for kk in list(kwargs.keys()) + list(args): if not allow_underscore_vars_in_init and kk.startswith('_'): raise ValueError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " + "Use the appropriate setter method instead. However, if you " @@ -92,21 +93,31 @@ def singleton_init(self, *args, **kwargs): # Monkey-patch __getattribute__ to assert the instance belongs to the current singleton original_getattribute = cls.__dict__.get('__getattribute__', None) - @functools.wraps(cls) + @functools.wraps(cls.__getattribute__) def singleton_getattribute(self, name): this_cls = type(self) - if not hasattr(this_cls, '_singleton_instance') or not super(cls, self).__getattribute__('_valid'): - raise RuntimeError(f"This instance of the singleton {this_cls.__name__} has been " - + "invalidated!") - if original_getattribute: - return original_getattribute(self, name) - else: - return super(cls, self).__getattribute__(name) + def _patch_getattribute(obj, this_name): + if original_getattribute: + return original_getattribute(obj, this_name) + else: + return super(cls, obj).__getattribute__(this_name) + if not hasattr(this_cls, '_singleton_instance') \ + or not _patch_getattribute(self, '_valid'): + raise RuntimeError(f"This instance of the singleton {this_cls.__name__} " + + "has been invalidated!") + return _patch_getattribute(self, name) cls.__getattribute__ = singleton_getattribute + # Add the get_self method to the class + if cls.__dict__.get('get_self', None) is not None: + raise ValueError(f"Class {cls} provides a 'get_self' method. This is not compatible " + + "with the singleton decorator!") @classmethod - @functools.wraps(cls) def get_self(this_cls, **kwargs): + """The get_self(**kwargs) method returns the singleton instance, allowing to pass + any kwargs to the constructor, even if they are not attributes of the singleton. + This is useful for kwargs filtering in getters or specific functions. + """ # Need to initialise in case the instance does not yet exist # (to recognise the allowed fields) this_cls() @@ -119,9 +130,16 @@ def get_self(this_cls, **kwargs): return this_cls(**filtered_kwargs) cls.get_self = get_self + # Add the delete method to the class + if cls.__dict__.get('delete', None) is not None: + raise ValueError(f"Class {cls} provides a 'delete' method. This is not compatible " + + "with the singleton decorator!") @classmethod - @functools.wraps(cls) def delete(this_cls): + """The delete() method removes the singleton and invalidates any existing instances, + allowing to create a new instance the next time the class is instantiated. This is + useful for resetting the singleton to its default values. + """ if hasattr(this_cls, '_singleton_instance'): # Invalidate (pointers to) existing instances! this_cls._singleton_instance._valid = False @@ -130,88 +148,8 @@ def delete(this_cls): return cls + # Hack to allow the decorator to be used with or without arguments if _cls is None: return decorator_singleton else: return decorator_singleton(_cls) - - -class ClassPropertyMeta(type): - def __setattr__(cls, key, value): - # Check if the attribute is a ClassProperty - for parent in cls.__mro__: - if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): - return parent.__dict__[key].__set__(cls, value) - return super(ClassPropertyMeta, cls).__setattr__(key, value) - - -class ClassProperty: - _registry = {} # Registry to store ClassProperty names for each class - - @classmethod - def get_properties(cls, owner, parents=True): - if not parents: - return cls._registry.get(owner, []) - else: - return [prop for parent in owner.__mro__ - for prop in cls._registry.get(parent, [])] - - def __init__(self, fget=None, fset=None, fdel=None, doc=None): - functools.update_wrapper(self, fget) - self.fget = fget - self.fset = fset - self.fdel = fdel - if doc is None and fget is not None: - doc = fget.__doc__ - self.__doc__ = doc - - def __set_name__(self, owner, name): - self.name = name - # Verify that the class is a subclass of ClassPropertyMeta - if ClassPropertyMeta not in type(owner).__mro__: - raise AttributeError(f"Class `{owner.__name__}` must be have ClassPropertyMeta " - + f"as a metaclass to be able to use ClassProperties!") - # Add the property name to the registry for the class - if owner not in ClassProperty._registry: - ClassProperty._registry[owner] = [] - ClassProperty._registry[owner].append(name) - # Create default getter, setter, and deleter - if self.fget is None: - def _getter(*args, **kwargs): - raise AttributeError(f"Unreadable attribute '{name}' of {owner.__name__} class!") - self.fget = _getter - if self.fset is None: - def _setter(self, *args, **kwargs): - raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no setter") - self.fset = _setter - if self.fdel is None: - def _deleter(*args, **kwargs): - raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no deleter") - self.fdel = _deleter - - def __get__(self, instance, owner): - if owner is None: - owner = type(instance) - try: - return self.fget(owner) - except ValueError: - # Return a fallback if initialisation fails - return None - - def __set__(self, cls, value): - self.fset(cls, value) - - def __delete__(self, instance): - self.fdel(instance.__class__) - - def getter(self, fget): - self.fget = fget - return self - - def setter(self, fset): - self.fset = fset - return self - - def deleter(self, fdel): - self.fdel = fdel - return self From 49b1bd8d3f6fabc1c9def4ffc12a125fe9afc349 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 17 Jan 2025 20:18:54 +0100 Subject: [PATCH 12/26] Rename singleton file and updated licence headers. --- xaux/dev_tools/gh.py | 5 ++ xaux/protectfile.py | 10 ++- xaux/tools/__init__.py | 5 +- xaux/tools/class_property.py | 85 +++++++++++++++++++++ xaux/tools/function_tools.py | 4 +- xaux/tools/{class_tools.py => singleton.py} | 0 6 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 xaux/tools/class_property.py rename xaux/tools/{class_tools.py => singleton.py} (100%) diff --git a/xaux/dev_tools/gh.py b/xaux/dev_tools/gh.py index 72a3684..a7ad93b 100644 --- a/xaux/dev_tools/gh.py +++ b/xaux/dev_tools/gh.py @@ -1,3 +1,8 @@ +# copyright ############################### # +# This file is part of the Xaux Package. # +# Copyright (c) CERN, 2024. # +# ######################################### # + import sys import json from pathlib import Path diff --git a/xaux/protectfile.py b/xaux/protectfile.py index 72cf6f7..d7bab6a 100644 --- a/xaux/protectfile.py +++ b/xaux/protectfile.py @@ -1,8 +1,10 @@ -""" -This package is an attempt to make file reading/writing (possibly concurrent) more reliable. +# copyright ############################### # +# This file is part of the Xaux Package. # +# Copyright (c) CERN, 2024. # +# ######################################### # + +# Last update 28/10/2024 - T. Pugnat and F.F. Van der Veken -Last update 28/10/2024 - T. Pugnat and F.F. Van der Veken -""" import sys import atexit import signal diff --git a/xaux/tools/__init__.py b/xaux/tools/__init__.py index 1c770f5..6088800 100644 --- a/xaux/tools/__init__.py +++ b/xaux/tools/__init__.py @@ -1,8 +1,9 @@ # copyright ############################### # # This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # +# Copyright (c) CERN, 2025. # # ######################################### # from .general_tools import * from .function_tools import * -from .class_tools import * +from .singleton import singleton +from .class_property import ClassProperty, ClassPropertyMeta diff --git a/xaux/tools/class_property.py b/xaux/tools/class_property.py new file mode 100644 index 0000000..84dee61 --- /dev/null +++ b/xaux/tools/class_property.py @@ -0,0 +1,85 @@ +# copyright ############################### # +# This file is part of the Xaux package. # +# Copyright (c) CERN, 2025. # +# ######################################### # + + +class ClassPropertyMeta(type): + def __setattr__(cls, key, value): + # Check if the attribute is a ClassProperty + for parent in cls.__mro__: + if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): + return parent.__dict__[key].__set__(cls, value) + return super(ClassPropertyMeta, cls).__setattr__(key, value) + + +class ClassProperty: + _registry = {} # Registry to store ClassProperty names for each class + + @classmethod + def get_properties(cls, owner, parents=True): + if not parents: + return cls._registry.get(owner, []) + else: + return [prop for parent in owner.__mro__ + for prop in cls._registry.get(parent, [])] + + def __init__(self, fget=None, fset=None, fdel=None, doc=None): + functools.update_wrapper(self, fget) + self.fget = fget + self.fset = fset + self.fdel = fdel + if doc is None and fget is not None: + doc = fget.__doc__ + self.__doc__ = doc + + def __set_name__(self, owner, name): + self.name = name + # Verify that the class is a subclass of ClassPropertyMeta + if ClassPropertyMeta not in type(owner).__mro__: + raise AttributeError(f"Class `{owner.__name__}` must be have ClassPropertyMeta " + + f"as a metaclass to be able to use ClassProperties!") + # Add the property name to the registry for the class + if owner not in ClassProperty._registry: + ClassProperty._registry[owner] = [] + ClassProperty._registry[owner].append(name) + # Create default getter, setter, and deleter + if self.fget is None: + def _getter(*args, **kwargs): + raise AttributeError(f"Unreadable attribute '{name}' of {owner.__name__} class!") + self.fget = _getter + if self.fset is None: + def _setter(self, *args, **kwargs): + raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no setter") + self.fset = _setter + if self.fdel is None: + def _deleter(*args, **kwargs): + raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no deleter") + self.fdel = _deleter + + def __get__(self, instance, owner): + if owner is None: + owner = type(instance) + try: + return self.fget(owner) + except ValueError: + # Return a fallback if initialisation fails + return None + + def __set__(self, cls, value): + self.fset(cls, value) + + def __delete__(self, instance): + self.fdel(instance.__class__) + + def getter(self, fget): + self.fget = fget + return self + + def setter(self, fset): + self.fset = fset + return self + + def deleter(self, fdel): + self.fdel = fdel + return self diff --git a/xaux/tools/function_tools.py b/xaux/tools/function_tools.py index b402c5b..c5fde84 100644 --- a/xaux/tools/function_tools.py +++ b/xaux/tools/function_tools.py @@ -1,6 +1,6 @@ # copyright ############################### # -# This file is part of the Xcoll Package. # -# Copyright (c) CERN, 2024. # +# This file is part of the Xaux package. # +# Copyright (c) CERN, 2025. # # ######################################### # import inspect diff --git a/xaux/tools/class_tools.py b/xaux/tools/singleton.py similarity index 100% rename from xaux/tools/class_tools.py rename to xaux/tools/singleton.py From f06f9c2084c696c705ea59308a156acdcb09dcb0 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 17 Jan 2025 20:19:08 +0100 Subject: [PATCH 13/26] Full singleton test --- tests/test_singleton.py | 639 +++++++++++++++++++++++++++++++++++----- 1 file changed, 568 insertions(+), 71 deletions(-) diff --git a/tests/test_singleton.py b/tests/test_singleton.py index ca9d14f..6860458 100644 --- a/tests/test_singleton.py +++ b/tests/test_singleton.py @@ -1,6 +1,6 @@ # copyright ############################### # -# This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # +# This file is part of the Xaux package. # +# Copyright (c) CERN, 2025. # # ######################################### # import pytest @@ -97,15 +97,6 @@ def __init__(self, value=3): assert not hasattr(SingletonClass1, '_singleton_instance') -def test_singleton_structure(): - with pytest.raises(ValueError, match=re.escape("Cannot create a singleton with an __init__ " - + "method that has more than one required argument (only 'self' is allowed)!")): - @singleton - class SingletonClass2: - def __init__(self, value): - self.value = value - - def test_nonsingleton_inheritance(): class NonSingletonParent: def __init__(self, value1=3): @@ -199,7 +190,7 @@ def __init__(self, *args, value2=-13, **kwargs): assert child2_instance1.value1 == 127 assert child2_instance1.value2 == 99 - # Assert the (non-singleton) ns_parent is not influenced by the children + # Assert the (non-singleton) parent is not influenced by the children ns_parent_instance3 = NonSingletonParent(value1=23) assert ns_parent_instance1 is not ns_parent_instance2 assert ns_parent_instance3 is not ns_parent_instance1 @@ -274,7 +265,7 @@ def __init__(self, *args, value2=0, **kwargs): assert child3_instance1 is not parent1_instance1 assert child3_instance1 is not parent1_instance2 # The parent values are INHERITED, but not SYNCED - assert child3_instance1.value1 == 3 + assert child3_instance1.value1 == 7 assert child3_instance1.value2 == 'lop' child3_instance2 = SingletonChild3(value1=-2) assert child3_instance2 is child3_instance1 @@ -306,7 +297,7 @@ def __init__(self, *args, value2=0, **kwargs): assert child4_instance1 is not child3_instance3 assert child4_instance1 is not child3_instance4 # The parent values are INHERITED, but not SYNCED - assert child4_instance1.value1 == 3 + assert child4_instance1.value1 == 7 assert child4_instance1.value2 == 0 child4_instance2 = SingletonChild4(value1=0.11) assert child4_instance2 is child4_instance1 @@ -400,7 +391,7 @@ def __init__(self, *args, value2=0, **kwargs): # Test the singleton child class without parent child3_instance5 = SingletonChild3() # The parent values are INHERITED, but not SYNCED - assert child3_instance5.value1 == 3 + assert child3_instance5.value1 == 7 assert child3_instance5.value2 == 'lop' child3_instance6 = SingletonChild3(value1=-2) assert child3_instance6 is child3_instance5 @@ -425,7 +416,7 @@ def __init__(self, *args, value2=0, **kwargs): assert child4_instance5 is not child3_instance7 assert child4_instance5 is not child3_instance8 # The parent values are INHERITED, but not SYNCED - assert child4_instance5.value1 == 3 + assert child4_instance5.value1 == 7 assert child4_instance5.value2 == 0 child4_instance6 = SingletonChild4(value1=0.11) assert child4_instance6 is child4_instance5 @@ -497,7 +488,7 @@ def __init__(self, *args, value2=0, **kwargs): assert child5_instance1 is not parent2_instance1 assert child5_instance1 is not parent2_instance2 # The parent values are INHERITED, but not SYNCED - assert child5_instance1.value1 == 3 + assert child5_instance1.value1 == 7 assert child5_instance1.value2 == 'lop' child5_instance2 = SingletonChild5(value1=-2) assert child5_instance2 is child5_instance1 @@ -529,7 +520,7 @@ def __init__(self, *args, value2=0, **kwargs): assert child6_instance1 is not child5_instance3 assert child6_instance1 is not child5_instance4 # The parent values are INHERITED, but not SYNCED - assert child6_instance1.value1 == 3 + assert child6_instance1.value1 == 7 assert child6_instance1.value2 == 0 child6_instance2 = SingletonChild6(value1=0.11) assert child6_instance2 is child6_instance1 @@ -623,7 +614,7 @@ def __init__(self, *args, value2=0, **kwargs): # Test the singleton child class without parent child5_instance5 = SingletonChild5() # The parent values are INHERITED, but not SYNCED - assert child5_instance5.value1 == 3 + assert child5_instance5.value1 == 7 assert child5_instance5.value2 == 'lop' child5_instance6 = SingletonChild5(value1=-2) assert child5_instance6 is child5_instance5 @@ -648,7 +639,7 @@ def __init__(self, *args, value2=0, **kwargs): assert child6_instance5 is not child5_instance7 assert child6_instance5 is not child5_instance8 # The parent values are INHERITED, but not SYNCED - assert child6_instance5.value1 == 3 + assert child6_instance5.value1 == 7 assert child6_instance5.value2 == 0 child6_instance6 = SingletonChild6(value1=0.11) assert child6_instance6 is child6_instance5 @@ -685,75 +676,187 @@ def __init__(self, *args, value2=0, **kwargs): assert not hasattr(SingletonChild6, '_singleton_instance') -def test_allow_underscore(): - @singleton(allow_underscore_vars_in_init=True) - class SingletonClass3: - def __init__(self, _value1=7.3, value2='hello'): - self._value1 = _value1 +def test_singleton_grand_inheritance(): + @singleton + class SingletonParent3: + def __init__(self, value1=4): + print("In SingletonParent2 __init__") + self.value1 = value1 + + class SingletonChild7(SingletonParent3): + def __init__(self, *args, value2='tsss', **kwargs): + print("In SingletonChild7 __init__") self.value2 = value2 + super().__init__(*args, **kwargs) + + class SingletonGrandChild(SingletonChild7): + def __init__(self, *args, value3='jeeej', **kwargs): + print("In SingletonGrandChild __init__") + self.value3 = value3 + super().__init__(*args, **kwargs) + + # Test the singleton parent class + parent3_instance1 = SingletonParent3() + assert parent3_instance1.value1 == 4 + parent3_instance2 = SingletonParent3(value1=5) + assert parent3_instance1 is parent3_instance2 + assert parent3_instance1.value1 == 5 + assert parent3_instance2.value1 == 5 + + # Test the singleton child class + child7_instance1 = SingletonChild7() + assert child7_instance1 is not parent3_instance1 + assert child7_instance1 is not parent3_instance2 + # The parent values are INHERITED, but not SYNCED + assert child7_instance1.value1 == 4 + assert child7_instance1.value2 == 'tsss' + child7_instance2 = SingletonChild7(value1=3, value2='pop') + assert child7_instance2 is child7_instance1 + assert child7_instance2 is not parent3_instance1 + assert child7_instance2 is not parent3_instance2 + assert child7_instance1.value1 == 3 + assert child7_instance1.value2 == 'pop' + + # Test the singleton grandchild class + grandchild_instance1 = SingletonGrandChild() + assert grandchild_instance1 is not parent3_instance1 + assert grandchild_instance1 is not parent3_instance2 + assert grandchild_instance1 is not child7_instance1 + assert grandchild_instance1 is not child7_instance2 + # The parent and grandparent values are INHERITED, but not SYNCED + assert grandchild_instance1.value1 == 4 + assert grandchild_instance1.value2 == 'tsss' + assert grandchild_instance1.value3 == 'jeeej' + assert child7_instance1.value1 == 3 + assert child7_instance1.value2 == 'pop' + assert parent3_instance1.value1 == 5 + grandchild_instance2 = SingletonGrandChild(value1=1, value2='doll', value3='happy') + assert grandchild_instance2 is grandchild_instance1 + assert grandchild_instance2 is not parent3_instance1 + assert grandchild_instance2 is not parent3_instance2 + assert grandchild_instance2 is not child7_instance1 + assert grandchild_instance2 is not child7_instance2 + assert grandchild_instance1.value1 == 1 + assert grandchild_instance1.value2 == 'doll' + assert grandchild_instance1.value3 == 'happy' + assert child7_instance1.value1 == 3 + assert child7_instance1.value2 == 'pop' + assert parent3_instance1.value1 == 5 + + # Now delete all and start fresh, to ensure grandchildren can instantiate without (grand)parents existing. + SingletonParent3.delete() + assert not hasattr(SingletonParent3, '_singleton_instance') + SingletonChild7.delete() + assert not hasattr(SingletonChild7, '_singleton_instance') + SingletonGrandChild.delete() + assert not hasattr(SingletonGrandChild, '_singleton_instance') + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonParent3 " + + "has been invalidated!"): + parent3_instance1.value1 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonParent3 " + + "has been invalidated!"): + parent3_instance2.value1 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonChild7 " + + "has been invalidated!"): + child7_instance1.value1 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonChild7 " + + "has been invalidated!"): + child7_instance1.value2 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonChild7 " + + "has been invalidated!"): + child7_instance2.value1 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonChild7 " + + "has been invalidated!"): + child7_instance2.value2 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonGrandChild " + + "has been invalidated!"): + grandchild_instance1.value1 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonGrandChild " + + "has been invalidated!"): + grandchild_instance1.value2 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonGrandChild " + + "has been invalidated!"): + grandchild_instance1.value3 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonGrandChild " + + "has been invalidated!"): + grandchild_instance2.value1 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonGrandChild " + + "has been invalidated!"): + grandchild_instance2.value2 + with pytest.raises(RuntimeError, match="This instance of the singleton SingletonGrandChild " + + "has been invalidated!"): + grandchild_instance2.value3 + + grandchild_instance3 = SingletonGrandChild() + assert grandchild_instance3 is not grandchild_instance1 + assert grandchild_instance3 is not grandchild_instance2 + assert grandchild_instance3 is not parent3_instance1 + assert grandchild_instance3 is not parent3_instance2 + assert grandchild_instance3 is not child7_instance1 + assert grandchild_instance3 is not child7_instance2 + assert grandchild_instance3.value1 == 4 + assert grandchild_instance3.value2 == 'tsss' + assert grandchild_instance3.value3 == 'jeeej' + grandchild_instance4 = SingletonGrandChild(value1=101, value2='poupee', value3='sad') + assert grandchild_instance4 is grandchild_instance3 + assert grandchild_instance4 is not grandchild_instance1 + assert grandchild_instance4 is not grandchild_instance2 + assert grandchild_instance4 is not parent3_instance1 + assert grandchild_instance4 is not parent3_instance2 + assert grandchild_instance4 is not child7_instance1 + assert grandchild_instance4 is not child7_instance2 + assert grandchild_instance3.value1 == 101 + assert grandchild_instance3.value2 == 'poupee' + assert grandchild_instance3.value3 == 'sad' + + # Clean up + SingletonGrandChild.delete() + assert not hasattr(SingletonGrandChild, '_singleton_instance') def test_get_self(): @singleton - class SingletonClass4: - def __init__(self, value=19): - self.value = value + class SingletonClass2: + def __init__(self, value1=19): + self.value1 = value1 # Initialise with default value - instance = SingletonClass4() - instance.value = 19 + instance = SingletonClass2() + assert instance.value1 == 19 # Get self with default value - self1 = SingletonClass4.get_self() + self1 = SingletonClass2.get_self() assert self1 is instance assert id(self1) == id(instance) - assert self1.value == 19 - assert instance.value == 19 + assert self1.value1 == 19 + assert instance.value1 == 19 # Get self with specific value - self2 = SingletonClass4.get_self(value=11) + self2 = SingletonClass2.get_self(value1=11) assert self2 is instance assert self2 is self1 - assert id(self2) == id(instance) - assert id(self2) == id(self1) - assert instance.value == 11 - assert self1.value == 11 - assert self2.value == 11 + assert instance.value1 == 11 # Get self with non-existing attribute - self3 = SingletonClass4.get_self(non_existing_attribute=13) + self3 = SingletonClass2.get_self(non_existing_attribute=13) assert self3 is instance assert self3 is self1 assert self3 is self2 - assert id(self3) == id(instance) - assert id(self3) == id(self1) - assert id(self3) == id(self2) - assert instance.value == 11 - assert self1.value == 11 - assert self2.value == 11 - assert self3.value == 11 - assert not hasattr(SingletonClass4, 'non_existing_attribute') + assert instance.value1 == 11 + assert not hasattr(SingletonClass2, 'non_existing_attribute') assert not hasattr(instance, 'non_existing_attribute') assert not hasattr(self1, 'non_existing_attribute') assert not hasattr(self2, 'non_existing_attribute') assert not hasattr(self3, 'non_existing_attribute') # Get self with specific value and non-existing attribute - self4 = SingletonClass4.get_self(value=12, non_existing_attribute=13) + self4 = SingletonClass2.get_self(value1=12, non_existing_attribute=13) assert self4 is instance assert self4 is self1 assert self4 is self2 assert self4 is self3 - assert id(self4) == id(instance) - assert id(self4) == id(self1) - assert id(self4) == id(self2) - assert id(self4) == id(self3) - assert instance.value == 12 - assert self1.value == 12 - assert self2.value == 12 - assert self3.value == 12 - assert self4.value == 12 - assert not hasattr(SingletonClass4, 'non_existing_attribute') + assert instance.value1 == 12 + assert not hasattr(SingletonClass2, 'non_existing_attribute') assert not hasattr(instance, 'non_existing_attribute') assert not hasattr(self1, 'non_existing_attribute') assert not hasattr(self2, 'non_existing_attribute') @@ -761,10 +864,11 @@ def __init__(self, value=19): assert not hasattr(self4, 'non_existing_attribute') # Remove the singleton - SingletonClass4.delete() + SingletonClass2.delete() + assert not hasattr(SingletonClass2, '_singleton_instance') # Initialise with get self with default value - self5 = SingletonClass4.get_self() + self5 = SingletonClass2.get_self() assert self5 is not instance assert self5 is not self1 assert self5 is not self2 @@ -775,13 +879,14 @@ def __init__(self, value=19): assert id(self5) != id(self2) assert id(self5) != id(self3) assert id(self5) != id(self4) - assert self5.value == 19 + assert self5.value1 == 19 # Remove the singleton - SingletonClass4.delete() + SingletonClass2.delete() + assert not hasattr(SingletonClass2, '_singleton_instance') # Initialise with get self with specific value - self6 = SingletonClass4.get_self(value=-3) + self6 = SingletonClass2.get_self(value1=-3) assert self6 is not instance assert self6 is not self1 assert self6 is not self2 @@ -794,23 +899,415 @@ def __init__(self, value=19): assert id(self6) != id(self3) assert id(self6) != id(self4) assert id(self6) != id(self5) - assert self6.value == -3 + assert self6.value1 == -3 + + class SingletonChild8(SingletonClass2): + def __init__(self, *args, value2=100, **kwargs): + print("In SingletonChild8 __init__") + self.value2 = value2 + super().__init__(*args, **kwargs) + + # Initialise child with default value + child = SingletonChild8() + child.value = 19 + + # Get self with default value + self7 = SingletonChild8.get_self() + assert self7 is child + assert self7.value == 19 + assert child is not instance + assert child is not self1 + + # Remove both singletons + SingletonClass2.delete() + assert not hasattr(SingletonClass2, '_singleton_instance') + SingletonChild8.delete() + assert not hasattr(SingletonChild8, '_singleton_instance') + + # Initialise child with default value + new_child = SingletonChild8() + assert new_child.value1 == 19 + assert new_child is not child + assert new_child is not instance + assert new_child is not self1 + + # Get self with default value + self7 = SingletonChild8.get_self() + assert self7 is new_child + assert self7.value1 == 19 + assert self7 is not instance + assert self7 is not self1 + assert self7 is not child + + # Get self with specific value + self8 = SingletonChild8.get_self(value1=11) + assert self8 is new_child + assert self8 is self7 + assert new_child.value1 == 11 + assert self8 is not instance + assert self8 is not self1 + assert self8 is not child + + # Get self with non-existing attribute + self9 = SingletonChild8.get_self(non_existing_attribute=13) + assert self9 is new_child + assert self9 is self7 + assert self9 is self8 + assert new_child.value1 == 11 + assert self9 is not instance + assert self9 is not self1 + assert self9 is not child + assert not hasattr(SingletonChild8, 'non_existing_attribute') + assert not hasattr(new_child, 'non_existing_attribute') + assert not hasattr(self7, 'non_existing_attribute') + assert not hasattr(self8, 'non_existing_attribute') + assert not hasattr(self9, 'non_existing_attribute') + + # Get self with specific value and non-existing attribute + self10 = SingletonChild8.get_self(value1=12, non_existing_attribute=13) + assert self10 is new_child + assert self10 is self7 + assert self10 is self8 + assert self10 is self9 + assert new_child.value1 == 12 + assert self10 is not instance + assert self10 is not self1 + assert self10 is not child + assert not hasattr(SingletonChild8, 'non_existing_attribute') + assert not hasattr(new_child, 'non_existing_attribute') + assert not hasattr(self7, 'non_existing_attribute') + assert not hasattr(self8, 'non_existing_attribute') + assert not hasattr(self9, 'non_existing_attribute') + assert not hasattr(self10, 'non_existing_attribute') + + # Remove the singleton + SingletonChild8.delete() + assert not hasattr(SingletonChild8, '_singleton_instance') + + # Initialise with get self with default value + self11 = SingletonChild8.get_self() + assert self11 is not instance + assert self11 is not self1 + assert self11 is not child + assert self11 is not new_child + assert self11 is not self7 + assert self11 is not self8 + assert self11 is not self9 + assert self11 is not self10 + assert id(self11) != id(new_child) + assert id(self11) != id(self7) + assert id(self11) != id(self8) + assert id(self11) != id(self9) + assert id(self11) != id(self10) + assert self11.value1 == 19 + + # Remove the singleton + SingletonChild8.delete() + assert not hasattr(SingletonChild8, '_singleton_instance') + + # Initialise with get self with specific value + self12 = SingletonChild8.get_self(value1=-3) + assert self12 is not instance + assert self12 is not self1 + assert self12 is not child + assert self12 is not new_child + assert self12 is not self7 + assert self12 is not self8 + assert self12 is not self9 + assert self12 is not self10 + assert self12 is not self11 + assert id(self12) != id(new_child) + assert id(self12) != id(self7) + assert id(self12) != id(self2) + assert id(self12) != id(self9) + assert id(self12) != id(self10) + assert id(self12) != id(self11) + assert self12.value1 == -3 + + # Clean up + SingletonChild8.delete() + assert not hasattr(SingletonChild8, '_singleton_instance') -def test_get_self_with_inheritance(): +def test_singleton_with_custom_dunder(): + @singleton + class SingletonClass3: + pass + + @singleton + class SingletonClass4: + def __new__(cls, *args, **kwargs): + print("In SingletonClass4 __new__") + instance = super(cls, cls).__new__(cls) + instance.test_var_new = 1 + return instance + @singleton class SingletonClass5: - def __init__(self, value=0.2): + def __init__(self, value='YASSS'): + print("In SingletonClass5 __init__") self.value = value + self.test_var_init = 10 - -def test_singleton_with_custom_new_and_init(): @singleton class SingletonClass6: + def __getattribute__(self, name): + print("In SingletonClass6 __getattribute__") + self.test_var_getattr = 100 + return super().__getattribute__(name) + + @singleton + class SingletonClass7: + def __new__(cls, *args, **kwargs): + print("In SingletonClass7 __new__") + instance = super(cls, cls).__new__(cls) + instance.test_var_new = 2 + return instance + def __init__(self, value='YASSS'): + print("In SingletonClass7 __init__") self.value = value + self.test_var_init = 20 + + def __getattribute__(self, name): + print("In SingletonClass7 __getattribute__") + self.test_var_getattr = 200 + return super().__getattribute__(name) + + # Test SingletonClass3 + class3_instance1 = SingletonClass3() + assert not hasattr(class3_instance1, 'test_var_new') + assert not hasattr(class3_instance1, 'test_var_init') + assert not hasattr(class3_instance1, 'test_var_getattr') + class3_instance2 = SingletonClass3() + assert class3_instance1 is class3_instance2 + assert not hasattr(class3_instance2, 'test_var_new') + assert not hasattr(class3_instance2, 'test_var_init') + assert not hasattr(class3_instance2, 'test_var_getattr') + + # Test SingletonClass4 + class4_instance1 = SingletonClass4() + assert hasattr(class4_instance1, 'test_var_new') + assert class4_instance1.test_var_new == 1 + assert not hasattr(class4_instance1, 'test_var_init') + assert not hasattr(class4_instance1, 'test_var_getattr') + class4_instance2 = SingletonClass4() + assert class4_instance1 is class4_instance2 + assert hasattr(class4_instance2, 'test_var_new') + assert class4_instance2.test_var_new == 1 + assert not hasattr(class4_instance2, 'test_var_init') + assert not hasattr(class4_instance2, 'test_var_getattr') + + # Test SingletonClass5 + class5_instance1 = SingletonClass5() + assert class5_instance1.value == 'YASSS' + assert not hasattr(class5_instance1, 'test_var_new') + assert hasattr(class5_instance1, 'test_var_init') + assert class5_instance1.test_var_init == 10 + assert not hasattr(class5_instance1, 'test_var_getattr') + class5_instance2 = SingletonClass5() + assert class5_instance1 is class5_instance2 + assert not hasattr(class5_instance2, 'test_var_new') + assert hasattr(class5_instance2, 'test_var_init') + assert class5_instance2.test_var_init == 10 + assert not hasattr(class5_instance2, 'test_var_getattr') + + # Test SingletonClass6 + class6_instance1 = SingletonClass6() + assert not hasattr(class6_instance1, 'test_var_new') + assert not hasattr(class6_instance1, 'test_var_init') + assert hasattr(class6_instance1, 'test_var_getattr') + assert class6_instance1.test_var_getattr == 100 + class6_instance2 = SingletonClass6() + assert class6_instance1 is class6_instance2 + assert not hasattr(class6_instance2, 'test_var_new') + assert not hasattr(class6_instance2, 'test_var_init') + assert hasattr(class6_instance2, 'test_var_getattr') + assert class6_instance2.test_var_getattr == 100 + + # Test SingletonClass7 + class7_instance1 = SingletonClass7() + assert class7_instance1.value == 'YASSS' + assert hasattr(class7_instance1, 'test_var_new') + assert class7_instance1.test_var_new == 2 + assert hasattr(class7_instance1, 'test_var_init') + assert class7_instance1.test_var_init == 20 + assert hasattr(class7_instance1, 'test_var_getattr') + assert class7_instance1.test_var_getattr == 200 + class7_instance2 = SingletonClass7() + assert class7_instance1 is class7_instance2 + assert class7_instance2.value == 'YASSS' + assert hasattr(class7_instance2, 'test_var_new') + assert class7_instance2.test_var_new == 2 + assert hasattr(class7_instance2, 'test_var_init') + assert class7_instance2.test_var_init == 20 + assert hasattr(class7_instance2, 'test_var_getattr') + assert class7_instance2.test_var_getattr == 200 + + # Clean up + SingletonClass3.delete() + assert not hasattr(SingletonClass3, '_singleton_instance') + SingletonClass4.delete() + assert not hasattr(SingletonClass4, '_singleton_instance') + SingletonClass5.delete() + assert not hasattr(SingletonClass5, '_singleton_instance') + SingletonClass6.delete() + assert not hasattr(SingletonClass6, '_singleton_instance') + SingletonClass7.delete() + assert not hasattr(SingletonClass7, '_singleton_instance') + +def test_singleton_with_custom_dunder_with_inheritance(): + @singleton + class SingletonParent4: + pass -def test_singleton_with_custom_new_and_init_with_inheritance(): - pass + @singleton + class SingletonParent5: + def __new__(cls, *args, **kwargs): + print("In SingletonParent5 __new__") + instance = super().__new__(cls, *args, **kwargs) + print(f"In SingletonParent5 __new__ {cls=} {type(instance)=}") + instance.test_var_new = 3 + return instance + def __init__(self, value='YASSS'): + print("In SingletonParent5 __init__") + self.value = value + self.test_var_init = 30 + + def __getattribute__(self, name): + print("In SingletonParent5 __getattribute__") + self.test_var_getattr = 300 + return super().__getattribute__(name) + + class SingletonChild9(SingletonParent4): + def __new__(cls, *args, **kwargs): + print("In SingletonChild9 __new__") + instance = super().__new__(cls, *args, **kwargs) + instance.test_var_new = 4 + return instance + + def __init__(self, value='YASSS'): + print("In SingletonChild9 __init__") + self.value = value + self.test_var_init = 40 + + def __getattribute__(self, name): + print("In SingletonChild9 __getattribute__") + self.test_var_getattr = 400 + return super().__getattribute__(name) + + class SingletonChild10(SingletonParent5): + pass + + # Test SingletonChild9 + child9_instance1 = SingletonChild9() + assert child9_instance1.value == 'YASSS' + assert hasattr(child9_instance1, 'test_var_new') + assert child9_instance1.test_var_new == 4 + assert hasattr(child9_instance1, 'test_var_init') + assert child9_instance1.test_var_init == 40 + assert hasattr(child9_instance1, 'test_var_getattr') + assert child9_instance1.test_var_getattr == 400 + child9_instance2 = SingletonChild9() + assert child9_instance1 is child9_instance2 + assert child9_instance2.value == 'YASSS' + assert hasattr(child9_instance2, 'test_var_new') + assert child9_instance2.test_var_new == 4 + assert hasattr(child9_instance2, 'test_var_init') + assert child9_instance2.test_var_init == 40 + assert hasattr(child9_instance2, 'test_var_getattr') + assert child9_instance2.test_var_getattr == 400 + + # Test SingletonChild10 + child10_instance1 = SingletonChild10() + assert child10_instance1.value == 'YASSS' + assert hasattr(child10_instance1, 'test_var_new') + assert child10_instance1.test_var_new == 3 + assert hasattr(child10_instance1, 'test_var_init') + assert child10_instance1.test_var_init == 30 + assert hasattr(child10_instance1, 'test_var_getattr') + assert child10_instance1.test_var_getattr == 300 + child10_instance2 = SingletonChild10() + assert child10_instance1 is child10_instance2 + assert child10_instance2.value == 'YASSS' + assert hasattr(child10_instance2, 'test_var_new') + assert child10_instance2.test_var_new == 3 + assert hasattr(child10_instance2, 'test_var_init') + assert child10_instance2.test_var_init == 30 + assert hasattr(child10_instance2, 'test_var_getattr') + assert child10_instance2.test_var_getattr == 300 + + # Clean up + SingletonParent4.delete() + assert not hasattr(SingletonParent4, '_singleton_instance') + SingletonParent5.delete() + assert not hasattr(SingletonParent5, '_singleton_instance') + SingletonChild9.delete() + assert not hasattr(SingletonChild9, '_singleton_instance') + SingletonChild10.delete() + assert not hasattr(SingletonChild10, '_singleton_instance') + + +def test_singleton_docstring(): + @singleton + class SingletonClass8: + """SingletonClass8 docstring test""" + + def __new__(cls, *args, **kwargs): + """SingletonClass8 __new__ docstring test""" + instance = super(cls, cls).__new__(cls) + return instance + + def __init__(self, value='YASSS'): + """SingletonClass8 __init__ docstring test""" + self.value = value + + def __getattribute__(self, name): + """SingletonClass8 __getattribute__ docstring test""" + return super().__getattribute__(name) + + assert singleton.__doc__.startswith("Singleton decorator.\n This decorator will redefine") + assert SingletonClass8.__doc__ == "SingletonClass8 docstring test" + assert SingletonClass8.__new__.__doc__ == "SingletonClass8 __new__ docstring test" + assert SingletonClass8.__init__.__doc__ == "SingletonClass8 __init__ docstring test" + assert SingletonClass8.__getattribute__.__doc__ == "SingletonClass8 __getattribute__ docstring test" + assert SingletonClass8.delete.__doc__.startswith("The delete() method removes the singleton") + assert SingletonClass8.get_self.__doc__.startswith("The get_self(**kwargs) method returns the") + + # Clean up + assert not hasattr(SingletonClass8, '_singleton_instance') + + +def test_singleton_structure(): + with pytest.raises(ValueError, match=re.escape("Cannot create a singleton with an __init__ " + + "method that has more than one required argument (only 'self' is allowed)!")): + @singleton + class SingletonClass9: + def __init__(self, value): + self.value = value + + @singleton(allow_underscore_vars_in_init=False) + class SingletonClass10: + def __init__(self): + self._value1 = 12 + + @singleton + class SingletonClass11: + def __init__(self, _value1=7.3, value2='hello'): + self._value1 = _value1 + self.value2 = value2 + + with pytest.raises(ValueError, match=re.escape("Cannot set private attribute _value1 for " + + "SingletonClass10! Use the appropriate setter method instead. However, if you " + + "really want to be able to set this attribute in the constructor, use " + + "'allow_underscore_vars_in_init=True' in the singleton decorator.")): + instance = SingletonClass10(_value1=10) + instance2 = SingletonClass11(_value1=10) + + # Clean up + SingletonClass10.delete() + assert not hasattr(SingletonClass10, '_singleton_instance') + SingletonClass11.delete() + assert not hasattr(SingletonClass11, '_singleton_instance') From 825166f8322a70529a05c6b3e50e5f490c296b90 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 17 Jan 2025 22:58:00 +0100 Subject: [PATCH 14/26] Expanded general tools and moved protectfile in tools --- tests/pytest_all_versions.sh | 2 +- tests/test_general_tools.py | 120 ++++++++++++++++++++++++++++++ xaux/__init__.py | 6 +- xaux/dev_tools/__init__.py | 4 +- xaux/dev_tools/package_manager.py | 2 +- xaux/fs/__init__.py | 2 +- xaux/tools/__init__.py | 7 +- xaux/tools/general_tools.py | 89 ++++++++++++++++++---- xaux/{ => tools}/protectfile.py | 31 +++----- 9 files changed, 218 insertions(+), 45 deletions(-) create mode 100644 tests/test_general_tools.py rename xaux/{ => tools}/protectfile.py (97%) diff --git a/tests/pytest_all_versions.sh b/tests/pytest_all_versions.sh index f5e92a7..bfcb930 100755 --- a/tests/pytest_all_versions.sh +++ b/tests/pytest_all_versions.sh @@ -6,7 +6,7 @@ files='' for i in 8 9 10 11 12 13 do source ~/miniforge3/bin/activate python3.$i - python -c "import sys; print(f'Testing xaux FS in Python version {sys.version.split()[0]}')" + python -c "import sys; print(f'Testing xaux in Python version {sys.version.split()[0]}')" pytest $files source ~/miniforge3/bin/activate done diff --git a/tests/test_general_tools.py b/tests/test_general_tools.py new file mode 100644 index 0000000..94e708d --- /dev/null +++ b/tests/test_general_tools.py @@ -0,0 +1,120 @@ +# copyright ############################### # +# This file is part of the Xaux Package. # +# Copyright (c) CERN, 2025. # +# ######################################### # + +import numpy as np +import xaux as xa + + +def test_timestamp(): + ts = xa.timestamp() + ts_f = xa.timestamp(in_filename=True) + ts_ms = xa.timestamp(ms=True) + ts_ms = xa.timestamp(us=True) + ts_ms_f = xa.timestamp(ms=True, in_filename=True) + + if ts.startswith('21'): + raise RuntimeError("You really should not be using this code anymore...") + + assert len(ts) == 19 + assert len(ts_f) == 19 + assert len(ts_ms) == 23 + assert len(ts_us) == 26 + assert len(ts_ms_f) == 23 + + assert len(ts.split()) == 2 + assert len(ts.split()[0]) == 10 + assert len(ts.split()[1]) == 8 + assert len(ts.split()[0].split('-')) == 3 + assert len(ts.split()[0].split('-')[0]) == 4 + assert len(ts.split()[0].split('-')[1]) == 2 + assert len(ts.split()[0].split('-')[2]) == 2 + assert len(ts.split()[1].split(':')) == 3 + assert len(ts.split()[1].split(':')[0]) == 2 + assert len(ts.split()[1].split(':')[1]) == 2 + assert len(ts.split()[1].split(':')[2]) == 2 + assert len(ts_f.split('_')) == 2 + assert len(ts_f.split('_')[0]) == 10 + assert len(ts_f.split('_')[1]) == 8 + assert len(ts_f.split('_')[0].split('-')) == 3 + assert len(ts_f.split('_')[0].split('-')[0]) == 4 + assert len(ts_f.split('_')[0].split('-')[1]) == 2 + assert len(ts_f.split('_')[0].split('-')[2]) == 2 + assert len(ts_f.split('_')[1].split('-')) == 3 + assert len(ts_f.split('_')[1].split('-')[0]) == 2 + assert len(ts_f.split('_')[1].split('-')[1]) == 2 + assert len(ts_f.split('_')[1].split('-')[2]) == 2 + assert len(ts_ms.split()) == 2 + assert len(ts_ms.split()[0]) == 10 + assert len(ts_ms.split()[1]) == 12 + assert len(ts_ms.split()[0].split('-')) == 3 + assert len(ts_ms.split()[0].split('-')[0]) == 4 + assert len(ts_ms.split()[0].split('-')[1]) == 2 + assert len(ts_ms.split()[0].split('-')[2]) == 2 + assert len(ts_ms.split()[1].split(':')) == 3 + assert len(ts_ms.split()[1].split(':')[0]) == 2 + assert len(ts_ms.split()[1].split(':')[1]) == 2 + assert len(ts_ms.split()[1].split(':')[2]) == 6 + assert len(ts_ms.split()[1].split(':')[2].split('.')) == 2 + assert len(ts_ms.split()[1].split(':')[2].split('.')[0]) == 2 + assert len(ts_ms.split()[1].split(':')[2].split('.')[1]) == 3 + assert len(ts_us.split()) == 2 + assert len(ts_us.split()[0]) == 10 + assert len(ts_us.split()[1]) == 15 + assert len(ts_us.split()[0].split('-')) == 3 + assert len(ts_us.split()[0].split('-')[0]) == 4 + assert len(ts_us.split()[0].split('-')[1]) == 2 + assert len(ts_us.split()[0].split('-')[2]) == 2 + assert len(ts_us.split()[1].split(':')) == 3 + assert len(ts_us.split()[1].split(':')[0]) == 2 + assert len(ts_us.split()[1].split(':')[1]) == 2 + assert len(ts_us.split()[1].split(':')[2]) == 9 + assert len(ts_us.split()[1].split(':')[2].split('.')) == 2 + assert len(ts_us.split()[1].split(':')[2].split('.')[0]) == 2 + assert len(ts_us.split()[1].split(':')[2].split('.')[1]) == 6 + assert len(ts_ms_f.split('_')) == 2 + assert len(ts_ms_f.split('_')[0]) == 10 + assert len(ts_ms_f.split('_')[1]) == 12 + assert len(ts_ms_f.split('_')[0].split('-')) == 3 + assert len(ts_ms_f.split('_')[0].split('-')[0]) == 4 + assert len(ts_ms_f.split('_')[0].split('-')[1]) == 2 + assert len(ts_ms_f.split('_')[0].split('-')[2]) == 2 + assert len(ts_ms_f.split('_')[1].split('-')) == 3 + assert len(ts_ms_f.split('_')[1].split('-')[0]) == 2 + assert len(ts_ms_f.split('_')[1].split('-')[1]) == 2 + assert len(ts_ms_f.split('_')[1].split('-')[2]) == 6 + assert len(ts_ms_f.split('_')[1].split('-')[2].split('.')) == 2 + assert len(ts_ms_f.split('_')[1].split('-')[2].split('.')[0]) == 2 + assert len(ts_ms_f.split('_')[1].split('-')[2].split('.')[1]) == 3 + + +def test_ranID(): + rans = [xa.ranID(length=l+1) for l in range(40)] + print(rans) + for i, ran in enumerate(rans): + assert len(ran) == int(np.ceil((i+1)/4)*4) + + rans = [xa.ranID(length=20) for _ in range(1000)] + for ran in rans: + base64_safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_' + assert np.all([c in base64_safe for c in ran]) + + rans = [xa.ranID(length=20, only_alphanumeric=True) for _ in range(1000)] + for ran in rans: + alnum = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + assert np.all([c in alnum for c in ran]) + + rans = xa.ranID(length=20, size=1000) + for ran in rans: + base64_safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_' + assert np.all([c in base64_safe for c in ran]) + + rans = xa.ranID(length=20, size=1000, only_alphanumeric=True) + for ran in rans: + alnum = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + assert np.all([c in alnum for c in ran]) + + +def test_system_lock(): + pass diff --git a/xaux/__init__.py b/xaux/__init__.py index cff8c3d..d65b6b0 100644 --- a/xaux/__init__.py +++ b/xaux/__init__.py @@ -4,7 +4,7 @@ # ######################################### # from .general import _pkg_root, __version__ -from .protectfile import ProtectFile, ProtectFileError, get_hash from .fs import FsPath, LocalPath, EosPath, AfsPath, afs_accessible, eos_accessible, is_egroup_member, cp, mv -from .dev_tools import import_package_version # Stub -from .tools import singleton, ClassProperty, ClassPropertyMeta +from .dev_tools import import_package_version # Stub to get dev_tools in the namespace +from .tools import singleton, ClassProperty, ClassPropertyMeta, timestamp, ranID, system_lock, get_hash, \ + ProtectFile, ProtectFileError diff --git a/xaux/dev_tools/__init__.py b/xaux/dev_tools/__init__.py index 2861d06..767def8 100644 --- a/xaux/dev_tools/__init__.py +++ b/xaux/dev_tools/__init__.py @@ -4,4 +4,6 @@ # ######################################### # from .release_tools import make_release, make_release_branch, rename_release_branch -from .package_manager import * +from .package_manager import import_package_version, install_package_version, get_package_versions, \ + get_latest_package_version, get_package_dependencies, \ + get_package_version_dependencies \ No newline at end of file diff --git a/xaux/dev_tools/package_manager.py b/xaux/dev_tools/package_manager.py index 346694d..971c3e6 100644 --- a/xaux/dev_tools/package_manager.py +++ b/xaux/dev_tools/package_manager.py @@ -1,6 +1,6 @@ # copyright ############################### # # This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # +# Copyright (c) CERN, 2025. # # ######################################### # import os diff --git a/xaux/fs/__init__.py b/xaux/fs/__init__.py index d89bcc1..687d206 100644 --- a/xaux/fs/__init__.py +++ b/xaux/fs/__init__.py @@ -7,7 +7,7 @@ from .afs import AfsPath, AfsPosixPath, AfsWindowsPath, afs_accessible from .eos import EosPath, EosPosixPath, EosWindowsPath from .eos_methods import eos_accessible, is_egroup_member -from .fs_methods import * +from .fs_methods import make_stat_result, size_expand from .io import cp, mv _xrdcp_use_ipv4 = True diff --git a/xaux/tools/__init__.py b/xaux/tools/__init__.py index 6088800..052bdbf 100644 --- a/xaux/tools/__init__.py +++ b/xaux/tools/__init__.py @@ -3,7 +3,10 @@ # Copyright (c) CERN, 2025. # # ######################################### # -from .general_tools import * -from .function_tools import * +from .general_tools import timestamp, ranID, system_lock, get_hash +from .function_tools import count_arguments, count_required_arguments, count_optional_arguments, \ + has_variable_length_arguments, has_variable_length_positional_arguments, \ + has_variable_length_keyword_arguments from .singleton import singleton from .class_property import ClassProperty, ClassPropertyMeta +from .protectfile import ProtectFile, ProtectFileError diff --git a/xaux/tools/general_tools.py b/xaux/tools/general_tools.py index 90d4252..4d84b65 100644 --- a/xaux/tools/general_tools.py +++ b/xaux/tools/general_tools.py @@ -1,28 +1,78 @@ # copyright ############################### # # This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # +# Copyright (c) CERN, 2025. # # ######################################### # -import os, sys -from ..fs import FsPath +import os +import sys import atexit import base64 -import datetime +import hashlib +import pandas as pd +import numpy as np + +from ..fs import FsPath -def timestamp(ms=False, in_filename=True): - ms = -3 if ms else -7 - if in_filename: - return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f")[:ms] - else: - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:ms] +def timestamp(*, in_filename=False, ms=False, us=False): + """Timestamp for easy use in logs and filenames. + Args: + ms (bool): If True, milliseconds are included. + Default False. + in_filename (bool): If True, colons are replaced + with dashes to be used in filenames. Default False. + Returns: + str: Current timestamp in UTC. + """ + if ms and us: + raise ValueError("Only one of 'ms' or 'us' can be True!") + idx = -3 if ms else -7 + idx = 26 if us else idx + form = "%Y-%m-%d_%H-%M-%S.%f" if in_filename else "%Y-%m-%d %H:%M:%S.%f" + return pd.Timestamp.now(tz='UTC').to_pydatetime().strftime(form)[:idx] -def ranID(): - ran = base64.urlsafe_b64encode(os.urandom(8)).decode('utf-8') - return ''.join(c if c.isalnum() else 'X' for c in ran) + +def ranID(*, length=12, size=1, only_alphanumeric=False): + """Base64 encoded random ID. + Args: + length (int): Length of the ID string, rounded up to + the closest multiple of 4. Default 12. + size (int): Number of random IDs to generate. + Default 1. + only_alphanumeric (bool): If True, only alphanumeric + characters are used. Default False. + Returns: + str: Random ID string. + """ + if length < 1: + raise ValueError("Length must be greater than 0!") + if size < 1: + raise ValueError("Size must be greater than 0!") + if size > 1: + return [ranID(length=length, only_alphanumeric=only_alphanumeric) + for _ in range(size)] + length = int(np.ceil(length/4)) + if only_alphanumeric: + ran = '' + for _ in range(length): + while True: + this_ran = ranID(length=4, only_alphanumeric=False) + if this_ran.isalnum(): + break + ran += this_ran + return ran + else: + random_bytes = os.urandom(3*length) + return base64.urlsafe_b64encode(random_bytes).decode('utf-8') -def lock(lockfile): +def system_lock(lockfile): + """Create a lockfile or quit the process if it already exists. + This is useful for cronjobs that might overlap in time if they + run for too long. + Args: + lockfile (str): Path to the lockfile. + """ lockfile = FsPath(lockfile) # Check if previous process still running if lockfile.exists(): @@ -33,3 +83,14 @@ def lock(lockfile): def exit_handler(): lockfile.unlink() atexit.register(exit_handler) + + +def get_hash(filename, *, size=128): + """Get a fast hash of a file, in chunks of 'size' (in kb)""" + h = hashlib.blake2b() + b = bytearray(size*1024) + mv = memoryview(b) + with open(filename, 'rb', buffering=0) as f: + for n in iter(lambda : f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() diff --git a/xaux/protectfile.py b/xaux/tools/protectfile.py similarity index 97% rename from xaux/protectfile.py rename to xaux/tools/protectfile.py index d7bab6a..e35aff1 100644 --- a/xaux/protectfile.py +++ b/xaux/tools/protectfile.py @@ -5,21 +5,19 @@ # Last update 28/10/2024 - T. Pugnat and F.F. Van der Veken +import os +import io import sys +import time +import json import atexit import signal -import traceback -import datetime -import hashlib -import io -import os import random -import time -import json +import traceback -from .fs import FsPath, EosPath -from .fs.temp import _tempdir -from .tools import ranID +from ..fs import FsPath, EosPath +from ..fs.temp import _tempdir +from .general_tools import ranID, get_hash, timestamp protected_open = {} @@ -48,17 +46,6 @@ def _register_exithandlers(obj): obj.__class__._exithandler_registered = True -def get_hash(filename, size=128): - """Get a fast hash of a file, in chunks of 'size' (in kb)""" - h = hashlib.blake2b() - b = bytearray(size*1024) - mv = memoryview(b) - with open(filename, 'rb', buffering=0) as f: - for n in iter(lambda : f.readinto(mv), 0): - h.update(mv[:n]) - return h.hexdigest() - - # TODO: there is some issue with the timestamps. Was this really a file # corruption, or is this an OS issue that we don't care about? # TODO: no stats on EOS files @@ -526,7 +513,7 @@ def stop_with_error(self, message): results_saved = False alt_file = None if self._use_temporary: - extension = f"__{datetime.datetime.now().isoformat()}.result" + extension = f"__{timestamp(us=True, in_filename=True)}.result" alt_file = FsPath(self.file.parent, self.file.name + extension).resolve() self.mv_temp(alt_file) results_saved = True From eb45bb223af181ec3f1949c718714fcc93e236ac Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 17 Jan 2025 22:58:18 +0100 Subject: [PATCH 15/26] Bugfix in singleton to work with python <= 3.9 --- tests/test_singleton.py | 2 +- xaux/tools/singleton.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_singleton.py b/tests/test_singleton.py index 6860458..0d018f9 100644 --- a/tests/test_singleton.py +++ b/tests/test_singleton.py @@ -1268,7 +1268,7 @@ def __getattribute__(self, name): """SingletonClass8 __getattribute__ docstring test""" return super().__getattribute__(name) - assert singleton.__doc__.startswith("Singleton decorator.\n This decorator will redefine") + assert singleton.__doc__.startswith("Singleton decorator.") assert SingletonClass8.__doc__ == "SingletonClass8 docstring test" assert SingletonClass8.__new__.__doc__ == "SingletonClass8 __new__ docstring test" assert SingletonClass8.__init__.__doc__ == "SingletonClass8 __init__ docstring test" diff --git a/xaux/tools/singleton.py b/xaux/tools/singleton.py index 82a8f6c..e0691eb 100644 --- a/xaux/tools/singleton.py +++ b/xaux/tools/singleton.py @@ -3,8 +3,8 @@ # Copyright (c) CERN, 2025. # # ######################################### # +import sys import functools -from pathlib import Path from .function_tools import count_required_arguments @@ -46,7 +46,10 @@ def singleton_new(this_cls, *args, **kwargs): if '_singleton_instance' not in this_cls.__dict__: if original_new: # This NEEDS to call 'this_cls' instead of 'cls' to avoid always spawning a cls instance - inst = original_new(this_cls, *args, **kwargs) + if sys.version_info >= (3, 10): + inst = original_new(this_cls, *args, **kwargs) + else: + inst = original_new.__func__(this_cls, *args, **kwargs) else: try: inst = super(cls, cls).__new__(this_cls, *args, **kwargs) From 7a580ba9c092b1b4a46ad35dc8f5986816fc85eb Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Sat, 18 Jan 2025 02:30:00 +0100 Subject: [PATCH 16/26] Protectile test in one example --- tests/_test_helpers.py | 83 -------- tests/test_protectfile.py | 253 +++++++++++++++++++++++- tests/test_protectfile_max_lock_time.py | 187 ------------------ 3 files changed, 248 insertions(+), 275 deletions(-) delete mode 100644 tests/_test_helpers.py delete mode 100644 tests/test_protectfile_max_lock_time.py diff --git a/tests/_test_helpers.py b/tests/_test_helpers.py deleted file mode 100644 index 273b626..0000000 --- a/tests/_test_helpers.py +++ /dev/null @@ -1,83 +0,0 @@ -# copyright ############################### # -# This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # -# ######################################### # - -import time -import json -import shutil -import os -import signal - -from xaux import ProtectFile, FsPath - -ProtectFile._debug = True -ProtectFile._testing = True - - -def rewrite(pf, runtime=0.2): - data = json.load(pf) - time.sleep(runtime) - data["myint"] += 1 - pf.seek(0) # revert point to beginning of file - json.dump(data, pf, indent=4, sort_keys=True) - pf.truncate() - - -def change_file_protected(fname, max_lock_time=None, error_queue=None, wait=0.1, runtime=0.2, job_id=None): - try: - if job_id: - t0 = time.time() - print(f"Job {job_id} started (stamp {t0})", flush=True) - with ProtectFile(fname, "r+", wait=wait, max_lock_time=max_lock_time) as pf: - if job_id: - t1 = time.time() - print(f"Job {job_id} in protectfile (init duration: {int(1e3*(t1 - t0))}ms)", flush=True) - rewrite(pf, runtime) - if job_id: - t2 = time.time() - print(f"Job {job_id} finished process in protectfile (process duration: {int(1e3*(t2 - t1))}ms)", flush=True) - if job_id: - t3 = time.time() - print(f"Job {job_id} done (total duration: {int(1e3*(t3-t0))}ms, exit duration {int(1e3*(t3-t2))}ms, stamp {t2})", flush=True) - except Exception as e: - if error_queue is None: - raise e - else: - error_queue.put(e) - return - - -def change_file_standard(fname): - with open(fname, "r+") as pf: # fails with this context - rewrite(pf) - return - - -def init_file(fname): - # Remove leftover lockfiles - for f in FsPath.cwd().glob(f"{fname}.lock*"): - f.unlink() - - # Initialise file - t_prev = time.time() - with ProtectFile(fname, "w", wait=0.1) as pf: - init_time = time.time() - t_prev - json.dump({"myint": 0}, pf, indent=4) - dump_time = time.time() - t_prev - init_time - exit_time = time.time() - t_prev - init_time - dump_time - - return init_time, dump_time, exit_time # These are the times taken by the ProtectFile process - - -def propagate_child_errors(error_queue): - while not error_queue.empty(): - raise error_queue.get() - - -def kill_process(proc, error_queue=None): - os.kill(proc.pid, signal.SIGKILL) - proc.join() - # Check if the process raised an error - if error_queue is not None: - propagate_child_errors(error_queue) diff --git a/tests/test_protectfile.py b/tests/test_protectfile.py index 2dd1903..ae06004 100644 --- a/tests/test_protectfile.py +++ b/tests/test_protectfile.py @@ -1,14 +1,84 @@ # copyright ############################### # # This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # +# Copyright (c) CERN, 2025. # # ######################################### # -from multiprocessing import Pool -import pytest +import os import json -from xaux import FsPath +import time +import pytest +import signal +from multiprocessing import Pool, Process, Queue + +from xaux import FsPath, ProtectFile + + +ProtectFile._debug = True +ProtectFile._testing = True + + +def rewrite(pf, runtime=0.2): + data = json.load(pf) + time.sleep(runtime) + data["myint"] += 1 + pf.seek(0) # revert point to beginning of file + json.dump(data, pf, indent=4, sort_keys=True) + pf.truncate() + +def change_file_protected(fname, max_lock_time=None, error_queue=None, wait=0.1, runtime=0.2, job_id=None): + try: + if job_id: + t0 = time.time() + print(f"Job {job_id} started (stamp {t0})", flush=True) + with ProtectFile(fname, "r+", wait=wait, max_lock_time=max_lock_time) as pf: + if job_id: + t1 = time.time() + print(f"Job {job_id} in protectfile (init duration: {int(1e3*(t1 - t0))}ms)", flush=True) + rewrite(pf, runtime) + if job_id: + t2 = time.time() + print(f"Job {job_id} finished process in protectfile (process duration: {int(1e3*(t2 - t1))}ms)", flush=True) + if job_id: + t3 = time.time() + print(f"Job {job_id} done (total duration: {int(1e3*(t3-t0))}ms, exit duration {int(1e3*(t3-t2))}ms, stamp {t2})", flush=True) + except Exception as e: + if error_queue is None: + raise e + else: + error_queue.put(e) + return + +def change_file_standard(fname): + with open(fname, "r+") as pf: # fails with this context + rewrite(pf) + return + +def init_file(fname): + # Remove leftover lockfiles + for f in FsPath.cwd().glob(f"{fname}.lock*"): + f.unlink() + + # Initialise file + t_prev = time.time() + with ProtectFile(fname, "w", wait=0.1) as pf: + init_time = time.time() - t_prev + json.dump({"myint": 0}, pf, indent=4) + dump_time = time.time() - t_prev - init_time + exit_time = time.time() - t_prev - init_time - dump_time + + return init_time, dump_time, exit_time # These are the times taken by the ProtectFile process + +def propagate_child_errors(error_queue): + while not error_queue.empty(): + raise error_queue.get() + +def kill_process(proc, error_queue=None): + os.kill(proc.pid, signal.SIGKILL) + proc.join() + # Check if the process raised an error + if error_queue is not None: + propagate_child_errors(error_queue) -from _test_helpers import init_file, change_file_protected, change_file_standard def test_deliberate_failure(): @@ -40,3 +110,176 @@ def test_protection(workers): FsPath(fname).unlink() + +# TODO: on some systems, multiprocessing can take considerable time to start up (then the test will fail) + +def test_normal_wait(): + fname = "test_normal_wait.json" + lock_file = f"{fname}.lock" + sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) + print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " + + f"~{1e3*sys_exit_time}ms on this system") + + t0 = time.time() + n_concurrent = 20 + + error_queue = Queue() + procs = [ + # args: name, max_lock_time, error_queue, wait, runtime, job_id + Process(target=change_file_protected, args=(fname, None, error_queue, 2, 1.5, i)) + for i in range(n_concurrent) + ] + + for proc in procs: + proc.start() + time.sleep(0.001) + + for proc in procs: + proc.join() + + propagate_child_errors(error_queue) + + with open(fname, "r+") as pf: + data = json.load(pf) + assert data["myint"] == n_concurrent + assert not FsPath(lock_file).exists() + + print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") + + FsPath(fname).unlink() + + +def test_normal_crashed(): + fname = "test_normal_wait.json" + lock_file = f"{fname}.lock" + sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) + print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " + + f"~{1e3*sys_exit_time}ms on this system") + + t0 = time.time() + n_concurrent = 20 + + error_queue = Queue() + procs = [ + # args: name, max_lock_time, error_queue, wait, runtime, job_id + Process(target=change_file_protected, args=(fname, None, error_queue, 2, 1.5, i)) + for i in range(n_concurrent) + ] + + for i, proc in enumerate(procs): + proc.start() + time.sleep(0.001) + if i == 0: + # This timing is very sensitive to the system; on some systems the process is almost finished + # after this time while on others it did not even start yet... + time.sleep(1.25) + kill_process(proc, error_queue) + with open(fname, "r") as pf: + data = json.load(pf) + assert data["myint"] == 0 + assert FsPath(lock_file).exists() + + # After a bit more than a minute, the situation should not have changed + time.sleep(90) + with open(fname, "r+") as pf: + data = json.load(pf) + assert data["myint"] == 0 + assert FsPath(lock_file).exists() + + # So we manually remove the lockfile, and the situation should resolve itself + FsPath(lock_file).unlink() + for proc in procs: + proc.join() + + propagate_child_errors(error_queue) + + with open(fname, "r+") as pf: + data = json.load(pf) + assert data["myint"] == n_concurrent - 1 + assert not FsPath(lock_file).exists() + + print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") + + FsPath(fname).unlink() + + +def test_max_lock_time_wait(): + fname = "test_max_lock_time_wait.json" + lock_file = f"{fname}.lock" + sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) + print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " + + f"~{1e3*sys_exit_time}ms on this system") + + t0 = time.time() + n_concurrent = 20 + + error_queue = Queue() + procs = [ + # args: name, max_lock_time, error_queue, wait, runtime, job_id + Process(target=change_file_protected, args=(fname, 90, error_queue, 2, 1.5, i)) + for i in range(n_concurrent) + ] + + for proc in procs: + proc.start() + time.sleep(0.001) + + for proc in procs: + proc.join() + + propagate_child_errors(error_queue) + + with open(fname, "r+") as pf: + data = json.load(pf) + assert data["myint"] == n_concurrent + assert not FsPath(lock_file).exists() + + print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") + + FsPath(fname).unlink() + + +def test_max_lock_time_crashed(): + fname = "test_max_lock_time_wait.json" + lock_file = f"{fname}.lock" + sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) + print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " + + f"~{1e3*sys_exit_time}ms on this system") + + t0 = time.time() + n_concurrent = 20 + + error_queue = Queue() + procs = [ + # args: name, max_lock_time, error_queue, wait, runtime, job_id + Process(target=change_file_protected, args=(fname, 90, error_queue, 2, 1.5, i)) + for i in range(n_concurrent) + ] + + for i, proc in enumerate(procs): + proc.start() + time.sleep(0.001) + if i == 0: + # This timing is very sensitive to the system; on some systems the process is almost finished + # after this time while on others it did not even start yet... + time.sleep(1.25) + kill_process(proc, error_queue) + with open(fname, "r") as pf: + data = json.load(pf) + assert data["myint"] == 0 + assert FsPath(lock_file).exists() + + # The situation should now resolve itself as there is a max_lock_time + for proc in procs: + proc.join() + + with open(fname, "r+") as pf: + data = json.load(pf) + assert data["myint"] == n_concurrent - 1 + assert not FsPath(lock_file).exists() + + propagate_child_errors(error_queue) + + print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") + + FsPath(fname).unlink() diff --git a/tests/test_protectfile_max_lock_time.py b/tests/test_protectfile_max_lock_time.py deleted file mode 100644 index 36cd325..0000000 --- a/tests/test_protectfile_max_lock_time.py +++ /dev/null @@ -1,187 +0,0 @@ -# copyright ############################### # -# This file is part of the Xaux Package. # -# Copyright (c) CERN, 2024. # -# ######################################### #import json - -from pathlib import Path -from multiprocessing import Process, Queue -import time -import pytest -import json - -from xaux import ProtectFile -from _test_helpers import init_file, change_file_protected, propagate_child_errors, kill_process - - -# TODO: on some systems, multiprocessing can take considerable time to start up (then the test will fail) - -def test_normal_wait(): - fname = "test_normal_wait.json" - lock_file = f"{fname}.lock" - sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) - print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " - + f"~{1e3*sys_exit_time}ms on this system") - - t0 = time.time() - n_concurrent = 20 - - error_queue = Queue() - procs = [ - # args: name, max_lock_time, error_queue, wait, runtime, job_id - Process(target=change_file_protected, args=(fname, None, error_queue, 2, 1.5, i)) - for i in range(n_concurrent) - ] - - for proc in procs: - proc.start() - time.sleep(0.001) - - for proc in procs: - proc.join() - - propagate_child_errors(error_queue) - - with open(fname, "r+") as pf: - data = json.load(pf) - assert data["myint"] == n_concurrent - assert not Path(lock_file).exists() - - print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") - - Path(fname).unlink() - - -def test_normal_crashed(): - fname = "test_normal_wait.json" - lock_file = f"{fname}.lock" - sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) - print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " - + f"~{1e3*sys_exit_time}ms on this system") - - t0 = time.time() - n_concurrent = 20 - - error_queue = Queue() - procs = [ - # args: name, max_lock_time, error_queue, wait, runtime, job_id - Process(target=change_file_protected, args=(fname, None, error_queue, 2, 1.5, i)) - for i in range(n_concurrent) - ] - - for i, proc in enumerate(procs): - proc.start() - time.sleep(0.001) - if i == 0: - # This timing is very sensitive to the system; on some systems the process is almost finished - # after this time while on others it did not even start yet... - time.sleep(1.25) - kill_process(proc, error_queue) - with open(fname, "r") as pf: - data = json.load(pf) - assert data["myint"] == 0 - assert Path(lock_file).exists() - - # After a bit more than a minute, the situation should not have changed - time.sleep(90) - with open(fname, "r+") as pf: - data = json.load(pf) - assert data["myint"] == 0 - assert Path(lock_file).exists() - - # So we manually remove the lockfile, and the situation should resolve itself - Path(lock_file).unlink() - for proc in procs: - proc.join() - - propagate_child_errors(error_queue) - - with open(fname, "r+") as pf: - data = json.load(pf) - assert data["myint"] == n_concurrent - 1 - assert not Path(lock_file).exists() - - print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") - - Path(fname).unlink() - - -def test_max_lock_time_wait(): - fname = "test_max_lock_time_wait.json" - lock_file = f"{fname}.lock" - sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) - print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " - + f"~{1e3*sys_exit_time}ms on this system") - - t0 = time.time() - n_concurrent = 20 - - error_queue = Queue() - procs = [ - # args: name, max_lock_time, error_queue, wait, runtime, job_id - Process(target=change_file_protected, args=(fname, 90, error_queue, 2, 1.5, i)) - for i in range(n_concurrent) - ] - - for proc in procs: - proc.start() - time.sleep(0.001) - - for proc in procs: - proc.join() - - propagate_child_errors(error_queue) - - with open(fname, "r+") as pf: - data = json.load(pf) - assert data["myint"] == n_concurrent - assert not Path(lock_file).exists() - - print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") - - Path(fname).unlink() - - -def test_max_lock_time_crashed(): - fname = "test_max_lock_time_wait.json" - lock_file = f"{fname}.lock" - sys_init_time, sys_dump_time, sys_exit_time = init_file(fname) - print(f"ProtectFile takes ~{1e3*sys_init_time}ms, ~{1e3*sys_dump_time}ms, " - + f"~{1e3*sys_exit_time}ms on this system") - - t0 = time.time() - n_concurrent = 20 - - error_queue = Queue() - procs = [ - # args: name, max_lock_time, error_queue, wait, runtime, job_id - Process(target=change_file_protected, args=(fname, 90, error_queue, 2, 1.5, i)) - for i in range(n_concurrent) - ] - - for i, proc in enumerate(procs): - proc.start() - time.sleep(0.001) - if i == 0: - # This timing is very sensitive to the system; on some systems the process is almost finished - # after this time while on others it did not even start yet... - time.sleep(1.25) - kill_process(proc, error_queue) - with open(fname, "r") as pf: - data = json.load(pf) - assert data["myint"] == 0 - assert Path(lock_file).exists() - - # The situation should now resolve itself as there is a max_lock_time - for proc in procs: - proc.join() - - with open(fname, "r+") as pf: - data = json.load(pf) - assert data["myint"] == n_concurrent - 1 - assert not Path(lock_file).exists() - - propagate_child_errors(error_queue) - - print(f"Total time for {n_concurrent} concurrent jobs: {time.time() - t0:.2f}s") - - Path(fname).unlink() From baa18059a83b13c5c1516fb7848a9f90091348a3 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Sat, 18 Jan 2025 03:20:20 +0100 Subject: [PATCH 17/26] Better testing of general tools --- tests/clean.sh | 4 +-- tests/cronjob_example.py | 18 +++++++++++ tests/test_general_tools.py | 61 +++++++++++++++++++++++++++++-------- 3 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 tests/cronjob_example.py diff --git a/tests/clean.sh b/tests/clean.sh index b2b5a96..8d4feee 100755 --- a/tests/clean.sh +++ b/tests/clean.sh @@ -6,7 +6,7 @@ do fi done -for f in example_file.txt +for f in example_file.txt test_*.json test_*.json.lock test_cronjob.txt do if [ -e $f ] then @@ -15,6 +15,6 @@ do done rm -r /eos/user/s/sixtadm/test_xboinc/* -rm -r /afs/cern.ch/user/s/sixtadm/public/test_xboinc +rm -r /afs/cern.ch/user/s/sixtadm/public/test_xboinc/* rm -r level1 level5_res diff --git a/tests/cronjob_example.py b/tests/cronjob_example.py new file mode 100644 index 0000000..44b9d5f --- /dev/null +++ b/tests/cronjob_example.py @@ -0,0 +1,18 @@ +# copyright ############################### # +# This file is part of the Xaux Package. # +# Copyright (c) CERN, 2025. # +# ######################################### # + +from time import sleep +from xaux import system_lock, FsPath + +system_lock(FsPath.cwd() / 'test_cronjob.lock') + +print("Cronjob running.") + +file = FsPath.cwd() / 'test_cronjob.txt' +file.touch() +sleep(5) +file.unlink() + +print("Cronjob finished.") diff --git a/tests/test_general_tools.py b/tests/test_general_tools.py index 94e708d..25359a4 100644 --- a/tests/test_general_tools.py +++ b/tests/test_general_tools.py @@ -3,16 +3,17 @@ # Copyright (c) CERN, 2025. # # ######################################### # +from subprocess import run, TimeoutExpired import numpy as np -import xaux as xa +from xaux import timestamp, ranID, get_hash, FsPath def test_timestamp(): - ts = xa.timestamp() - ts_f = xa.timestamp(in_filename=True) - ts_ms = xa.timestamp(ms=True) - ts_ms = xa.timestamp(us=True) - ts_ms_f = xa.timestamp(ms=True, in_filename=True) + ts = timestamp() + ts_f = timestamp(in_filename=True) + ts_ms = timestamp(ms=True) + ts_us = timestamp(us=True) + ts_ms_f = timestamp(ms=True, in_filename=True) if ts.startswith('21'): raise RuntimeError("You really should not be using this code anymore...") @@ -90,31 +91,67 @@ def test_timestamp(): def test_ranID(): - rans = [xa.ranID(length=l+1) for l in range(40)] + rans = [ranID(length=l+1) for l in range(40)] print(rans) for i, ran in enumerate(rans): assert len(ran) == int(np.ceil((i+1)/4)*4) - rans = [xa.ranID(length=20) for _ in range(1000)] + rans = [ranID(length=20) for _ in range(1000)] for ran in rans: base64_safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_' assert np.all([c in base64_safe for c in ran]) - rans = [xa.ranID(length=20, only_alphanumeric=True) for _ in range(1000)] + rans = [ranID(length=20, only_alphanumeric=True) for _ in range(1000)] for ran in rans: alnum = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' assert np.all([c in alnum for c in ran]) - rans = xa.ranID(length=20, size=1000) + rans = ranID(length=20, size=1000) for ran in rans: base64_safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_' assert np.all([c in base64_safe for c in ran]) - rans = xa.ranID(length=20, size=1000, only_alphanumeric=True) + rans = ranID(length=20, size=1000, only_alphanumeric=True) for ran in rans: alnum = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' assert np.all([c in alnum for c in ran]) def test_system_lock(): - pass + datafile = FsPath.cwd() / 'test_cronjob.txt' + lockfile = FsPath.cwd() / 'test_cronjob.lock' + if datafile.exists(): + datafile.unlink() + if lockfile.exists(): + lockfile.unlink() + + # Normal run + cmd1 = run(['python', 'cronjob_example.py'], capture_output=True, text=True) + assert cmd1.returncode == 0 + assert "Cronjob running." in cmd1.stdout + assert "Cronjob finished." in cmd1.stdout + assert not lockfile.exists() + assert not datafile.exists() + + # Run and kill halfway + try: + cmd2 = run(['python', 'cronjob_example.py'], timeout=2, capture_output=True, text=True) + except TimeoutExpired: + assert lockfile.exists() + assert datafile.exists() + datafile.unlink() + + # Run while lockfile exists + cmd3 = run(['python', 'cronjob_example.py'], capture_output=True, text=True) + assert cmd3.returncode == 1 + assert "Cronjob running." not in cmd3.stdout + assert "Cronjob finished." not in cmd3.stdout + assert "Previous test_cronjob.lock script still active!" in cmd3.stderr + assert not datafile.exists() + lockfile.unlink() + + +def test_hash(): + hs = get_hash('test_singleton.py') + assert hs == '2d4af659d0fdd2779492ffb937e4d04c9eac3bae0c02678297a83077f9548c5001a8b47'\ + + '71512a02b85628cf5736ebbf7d30071031deb79aa5a391283a093ea4a' From f9e1ad5ea881c1ba550c3bfa10630b0d4d61c89a Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Sat, 18 Jan 2025 21:06:05 +0100 Subject: [PATCH 18/26] Reimplemented singleton decorator as subclass. This is much cleaner. --- tests/test_singleton.py | 8 +- xaux/tools/singleton.py | 249 ++++++++++++++++++++++------------------ 2 files changed, 139 insertions(+), 118 deletions(-) diff --git a/tests/test_singleton.py b/tests/test_singleton.py index 0d018f9..c96e142 100644 --- a/tests/test_singleton.py +++ b/tests/test_singleton.py @@ -1038,7 +1038,7 @@ class SingletonClass3: class SingletonClass4: def __new__(cls, *args, **kwargs): print("In SingletonClass4 __new__") - instance = super(cls, cls).__new__(cls) + instance = super().__new__(cls) instance.test_var_new = 1 return instance @@ -1060,7 +1060,7 @@ def __getattribute__(self, name): class SingletonClass7: def __new__(cls, *args, **kwargs): print("In SingletonClass7 __new__") - instance = super(cls, cls).__new__(cls) + instance = super().__new__(cls) instance.test_var_new = 2 return instance @@ -1166,7 +1166,7 @@ class SingletonParent4: class SingletonParent5: def __new__(cls, *args, **kwargs): print("In SingletonParent5 __new__") - instance = super().__new__(cls, *args, **kwargs) + instance = super().__new__(cls) print(f"In SingletonParent5 __new__ {cls=} {type(instance)=}") instance.test_var_new = 3 return instance @@ -1257,7 +1257,7 @@ class SingletonClass8: def __new__(cls, *args, **kwargs): """SingletonClass8 __new__ docstring test""" - instance = super(cls, cls).__new__(cls) + instance = super().__new__(cls) return instance def __init__(self, value='YASSS'): diff --git a/xaux/tools/singleton.py b/xaux/tools/singleton.py index e0691eb..734a995 100644 --- a/xaux/tools/singleton.py +++ b/xaux/tools/singleton.py @@ -11,10 +11,37 @@ # TODO: allow_underscore_vars_in_init=False is not very robust. Do we need it for ClassProperty? + +# Stub class for docstrings and logical class naming +class Singleton: + # Stub for naming + def __new__(cls, *args, **kwargs): + """The __new__ method is expanded to implement the singleton.""" + def __init__(self, *args, **kwargs): + """The __init__ method is expanded to implement the singleton.""" + def __str__(self): + """This a default __str__ method for the singleton.""" + def __repr__(self): + """This a default __repr__ method for the singleton.""" + def __getattribute__(self, name): + """The __getattribute__ method is expanded to implement the singleton.""" + def get_self(cls, **kwargs): + """The get_self(**kwargs) method returns the singleton instance, allowing to pass + any kwargs to the constructor, even if they are not attributes of the singleton. + This is useful for kwargs filtering in getters or specific functions. + """ + def delete(cls): + """The delete() method removes the singleton and invalidates any existing instances, + allowing to create a new instance the next time the class is instantiated. This is + useful for resetting the singleton to its default values. + """ + + def singleton(_cls=None, *, allow_underscore_vars_in_init=True): """Singleton decorator. - This decorator will redefine a class such that only one instance exists and the same - instance is returned every time the class is instantiated. + This decorator will redefine a class (by letting it inherit from itself and renaming it) + such that only one instance exists and the same instance is returned every time the + class is instantiated. - Each re-initialisation of the singleton will keep the class attributes, and for this reason, the __init__ method should not have any required arguments. This is asserted at the class creation time. @@ -29,127 +56,121 @@ class will be its own singleton. for the singleton (these will then just be ignored). This is useful for kwargs filtering in getters or specific functions. """ - # Caveat I: whenever any monkey-patched method is called, the super method should be - # called on the original singleton class (not the current class), to avoid infinite - # loops (it would just call the same method again). - # Caveat II: When we need to get the current class of an instance, + # Caveat: When we need to get the current class of an instance, # we should use type(self) instead of self.__class__, as the latter will get into an # infinite loop because of the __getattribute__ method. # Internal decorator definition to used without arguments def decorator_singleton(cls): - # Monkey-patch __new__ to create a singleton - original_new = cls.__dict__.get('__new__', None) - @functools.wraps(cls.__new__) - def singleton_new(this_cls, *args, **kwargs): - # If the singleton instance does not exist, create it - if '_singleton_instance' not in this_cls.__dict__: - if original_new: - # This NEEDS to call 'this_cls' instead of 'cls' to avoid always spawning a cls instance - if sys.version_info >= (3, 10): - inst = original_new(this_cls, *args, **kwargs) - else: - inst = original_new.__func__(this_cls, *args, **kwargs) - else: - try: - inst = super(cls, cls).__new__(this_cls, *args, **kwargs) - except TypeError: - inst = super(cls, cls).__new__(this_cls) - inst._initialised = False - inst._valid = True - this_cls._singleton_instance = inst - return this_cls._singleton_instance - cls.__new__ = singleton_new - - # Monkey-patch __init__ to set the singleton fields - original_init = cls.__dict__.get('__init__', None) - if original_init: - if count_required_arguments(original_init) > 1: - raise ValueError(f"Cannot create a singleton with an __init__ method that " - + "has more than one required argument (only 'self' is allowed)!") - @functools.wraps(cls.__init__) - def singleton_init(self, *args, **kwargs): - this_cls = type(self) - # Validate kwargs - kwargs.pop('_initialised', None) - for kk in list(kwargs.keys()) + list(args): - if not allow_underscore_vars_in_init and kk.startswith('_'): - raise ValueError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " - + "Use the appropriate setter method instead. However, if you " - + "really want to be able to set this attribute in the " - + "constructor, use 'allow_underscore_vars_in_init=True' " - + "in the singleton decorator.") - # Initialise the singleton if it has not been initialised yet - if not self._initialised: - if original_init: - original_init(self, *args, **kwargs) - else: - super(cls, self).__init__(*args, **kwargs) - self._initialised = True - # Set the attributes; only attributes defined in the class, custom init, or properties - # are allowed - for kk, vv in kwargs.items(): - if not hasattr(self, kk) and not hasattr(this_cls, kk): - raise ValueError(f"Invalid attribute {kk} for {this_cls.__name__}!") - setattr(self, kk, vv) - cls.__init__ = singleton_init - - # Monkey-patch __getattribute__ to assert the instance belongs to the current singleton - original_getattribute = cls.__dict__.get('__getattribute__', None) - @functools.wraps(cls.__getattribute__) - def singleton_getattribute(self, name): - this_cls = type(self) - def _patch_getattribute(obj, this_name): - if original_getattribute: - return original_getattribute(obj, this_name) - else: - return super(cls, obj).__getattribute__(this_name) - if not hasattr(this_cls, '_singleton_instance') \ - or not _patch_getattribute(self, '_valid'): - raise RuntimeError(f"This instance of the singleton {this_cls.__name__} " - + "has been invalidated!") - return _patch_getattribute(self, name) - cls.__getattribute__ = singleton_getattribute - - # Add the get_self method to the class - if cls.__dict__.get('get_self', None) is not None: + # Verify any existing __init__ method only has optional values + original_init = cls.__dict__.get('__init__') + if original_init and count_required_arguments(original_init) > 1: + raise ValueError(f"Cannot create a singleton with an __init__ method that " + + "has more than one required argument (only 'self' is allowed)!") + + # Check the class doesn't already have a get_self method + if cls.__dict__.get('get_self'): raise ValueError(f"Class {cls} provides a 'get_self' method. This is not compatible " + "with the singleton decorator!") - @classmethod - def get_self(this_cls, **kwargs): - """The get_self(**kwargs) method returns the singleton instance, allowing to pass - any kwargs to the constructor, even if they are not attributes of the singleton. - This is useful for kwargs filtering in getters or specific functions. - """ - # Need to initialise in case the instance does not yet exist - # (to recognise the allowed fields) - this_cls() - filtered_kwargs = {key: value for key, value in kwargs.items() - if hasattr(this_cls, key) \ - or hasattr(this_cls._singleton_instance, key)} - if not allow_underscore_vars_in_init: - filtered_kwargs = {key: value for key, value in filtered_kwargs.items() - if not key.startswith('_')} - return this_cls(**filtered_kwargs) - cls.get_self = get_self - - # Add the delete method to the class - if cls.__dict__.get('delete', None) is not None: + + # Check the class doesn't already have a delete method + if cls.__dict__.get('delete'): raise ValueError(f"Class {cls} provides a 'delete' method. This is not compatible " + "with the singleton decorator!") - @classmethod - def delete(this_cls): - """The delete() method removes the singleton and invalidates any existing instances, - allowing to create a new instance the next time the class is instantiated. This is - useful for resetting the singleton to its default values. - """ - if hasattr(this_cls, '_singleton_instance'): - # Invalidate (pointers to) existing instances! - this_cls._singleton_instance._valid = False - del this_cls._singleton_instance - cls.delete = delete - - return cls + + # Define wrapper names + wrap_new = cls.__new__ if cls.__dict__.get('__new__') else Singleton.__new__ + wrap_init = cls.__init__ if cls.__dict__.get('__init__') else Singleton.__init__ + wrap_getattribute = cls.__getattribute__ if cls.__dict__.get('__getattribute__') \ + else Singleton.__getattribute__ + + @functools.wraps(cls, updated=()) + class LocalSingleton(cls): + __original_nonsingleton_class__ = cls + + @functools.wraps(wrap_new) + def __new__(this_cls, *args, **kwargs): + # If the singleton instance does not exist, create it + if '_singleton_instance' not in this_cls.__dict__: + try: + inst = super().__new__(this_cls, *args, **kwargs) + except TypeError: + # object._new__ does not accept arguments + inst = super().__new__(this_cls) + inst._initialised = False + inst._valid = True + this_cls._singleton_instance = inst + return this_cls._singleton_instance + + @functools.wraps(wrap_init) + def __init__(self, *args, **kwargs): + this_cls = type(self) + # Validate kwargs + kwargs.pop('_initialised', None) + for kk in list(kwargs.keys()) + list(args): + if not allow_underscore_vars_in_init and kk.startswith('_'): + raise ValueError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " + + "Use the appropriate setter method instead. However, if you " + + "really want to be able to set this attribute in the " + + "constructor, use 'allow_underscore_vars_in_init=True' " + + "in the singleton decorator.") + # Initialise the singleton if it has not been initialised yet + if not self._initialised: + super().__init__(*args, **kwargs) + self._initialised = True + # Set the attributes; only attributes defined in the class, custom init, or properties + # are allowed + for kk, vv in kwargs.items(): + if not hasattr(self, kk) and not hasattr(this_cls, kk): + raise ValueError(f"Invalid attribute {kk} for {this_cls.__name__}!") + setattr(self, kk, vv) + + if not cls.__dict__.get('__str__'): + @functools.wraps(Singleton.__str__) + def __str__(self): + return f"<{type(self).__name__} singleton instance>" + + if not cls.__dict__.get('__repr__'): + @functools.wraps(Singleton.__repr__) + def __repr__(self): + return f"<{type(self).__name__} singleton instance at {hex(id(self))}>" + + @functools.wraps(wrap_getattribute) + def __getattribute__(self, name): + this_cls = type(self) + if not hasattr(this_cls, '_singleton_instance') \ + or not super().__getattribute__('_valid'): + raise RuntimeError(f"This instance of the singleton {this_cls.__name__} " + + "has been invalidated!") + return super().__getattribute__(name) + + @classmethod + @functools.wraps(Singleton.get_self) + def get_self(this_cls, **kwargs): + # Need to initialise in case the instance does not yet exist + # (to recognise the allowed fields) + this_cls() + filtered_kwargs = {key: value for key, value in kwargs.items() + if hasattr(this_cls, key) \ + or hasattr(this_cls._singleton_instance, key)} + if not allow_underscore_vars_in_init: + filtered_kwargs = {key: value for key, value in filtered_kwargs.items() + if not key.startswith('_')} + return this_cls(**filtered_kwargs) + + @classmethod + @functools.wraps(Singleton.delete) + def delete(this_cls): + if hasattr(this_cls, '_singleton_instance'): + # Invalidate (pointers to) existing instances! + this_cls._singleton_instance._valid = False + del this_cls._singleton_instance + + # Rename the original class, for clarity in the __mro__ etc + cls.__name__ = f"{cls.__name__}Original" + cls.__qualname__ = f"{cls.__qualname__}Original" + + return LocalSingleton # Hack to allow the decorator to be used with or without arguments if _cls is None: From 269e3e0d54e772a21e334a7ed13e6c9239eae2aa Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Thu, 23 Jan 2025 02:58:43 +0100 Subject: [PATCH 19/26] Small changes in singleton --- tests/test_singleton.py | 38 ++++++++++++++++++++++++++------------ xaux/tools/singleton.py | 37 ++++++++++++++++++++----------------- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/tests/test_singleton.py b/tests/test_singleton.py index c96e142..3eabf30 100644 --- a/tests/test_singleton.py +++ b/tests/test_singleton.py @@ -1281,33 +1281,47 @@ def __getattribute__(self, name): def test_singleton_structure(): - with pytest.raises(ValueError, match=re.escape("Cannot create a singleton with an __init__ " - + "method that has more than one required argument (only 'self' is allowed)!")): + with pytest.raises(TypeError, match=re.escape("Cannot create a singleton for class SingletonClass9 " + + "with an __init__ method that has more than one required argument (only 'self' is allowed)!")): @singleton class SingletonClass9: def __init__(self, value): self.value = value + with pytest.raises(TypeError, match=re.escape("Class SingletonClass10 provides a 'get_self' " + + "method. This is not compatible with the singleton decorator!")): + @singleton + class SingletonClass10: + def get_self(self, *args, **kwargs): + return 20 + + with pytest.raises(TypeError, match=re.escape("Class SingletonClass11 provides a 'delete' " + + "method. This is not compatible with the singleton decorator!")): + @singleton + class SingletonClass11: + def delete(self, *args, **kwargs): + pass + @singleton(allow_underscore_vars_in_init=False) - class SingletonClass10: + class SingletonClass12: def __init__(self): self._value1 = 12 @singleton - class SingletonClass11: + class SingletonClass13: def __init__(self, _value1=7.3, value2='hello'): self._value1 = _value1 self.value2 = value2 - with pytest.raises(ValueError, match=re.escape("Cannot set private attribute _value1 for " - + "SingletonClass10! Use the appropriate setter method instead. However, if you " + with pytest.raises(AttributeError, match=re.escape("Cannot set private attribute _value1 for " + + "SingletonClass12! Use the appropriate setter method instead. However, if you " + "really want to be able to set this attribute in the constructor, use " + "'allow_underscore_vars_in_init=True' in the singleton decorator.")): - instance = SingletonClass10(_value1=10) - instance2 = SingletonClass11(_value1=10) + instance = SingletonClass12(_value1=10) + instance2 = SingletonClass13(_value1=10) # Clean up - SingletonClass10.delete() - assert not hasattr(SingletonClass10, '_singleton_instance') - SingletonClass11.delete() - assert not hasattr(SingletonClass11, '_singleton_instance') + SingletonClass12.delete() + assert not hasattr(SingletonClass12, '_singleton_instance') + SingletonClass13.delete() + assert not hasattr(SingletonClass13, '_singleton_instance') diff --git a/xaux/tools/singleton.py b/xaux/tools/singleton.py index 734a995..02106a5 100644 --- a/xaux/tools/singleton.py +++ b/xaux/tools/singleton.py @@ -64,25 +64,28 @@ class will be its own singleton. def decorator_singleton(cls): # Verify any existing __init__ method only has optional values original_init = cls.__dict__.get('__init__') - if original_init and count_required_arguments(original_init) > 1: - raise ValueError(f"Cannot create a singleton with an __init__ method that " - + "has more than one required argument (only 'self' is allowed)!") + if original_init is not None and count_required_arguments(original_init) > 1: + raise TypeError(f"Cannot create a singleton for class {cls.__name__} with an " + + f"__init__ method that has more than one required argument (only " + + f"'self' is allowed)!") # Check the class doesn't already have a get_self method - if cls.__dict__.get('get_self'): - raise ValueError(f"Class {cls} provides a 'get_self' method. This is not compatible " - + "with the singleton decorator!") + if cls.__dict__.get('get_self') is not None: + raise TypeError(f"Class {cls.__name__} provides a 'get_self' method. This is not " + + "compatible with the singleton decorator!") # Check the class doesn't already have a delete method - if cls.__dict__.get('delete'): - raise ValueError(f"Class {cls} provides a 'delete' method. This is not compatible " - + "with the singleton decorator!") + if cls.__dict__.get('delete') is not None: + raise TypeError(f"Class {cls.__name__} provides a 'delete' method. This is not " + + "compatible with the singleton decorator!") # Define wrapper names - wrap_new = cls.__new__ if cls.__dict__.get('__new__') else Singleton.__new__ - wrap_init = cls.__init__ if cls.__dict__.get('__init__') else Singleton.__init__ + wrap_new = cls.__new__ if cls.__dict__.get('__new__') is not None \ + else Singleton.__new__ + wrap_init = cls.__init__ if cls.__dict__.get('__init__') is not None \ + else Singleton.__init__ wrap_getattribute = cls.__getattribute__ if cls.__dict__.get('__getattribute__') \ - else Singleton.__getattribute__ + is not None else Singleton.__getattribute__ @functools.wraps(cls, updated=()) class LocalSingleton(cls): @@ -109,7 +112,7 @@ def __init__(self, *args, **kwargs): kwargs.pop('_initialised', None) for kk in list(kwargs.keys()) + list(args): if not allow_underscore_vars_in_init and kk.startswith('_'): - raise ValueError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " + raise AttributeError(f"Cannot set private attribute {kk} for {this_cls.__name__}! " + "Use the appropriate setter method instead. However, if you " + "really want to be able to set this attribute in the " + "constructor, use 'allow_underscore_vars_in_init=True' " @@ -122,15 +125,15 @@ def __init__(self, *args, **kwargs): # are allowed for kk, vv in kwargs.items(): if not hasattr(self, kk) and not hasattr(this_cls, kk): - raise ValueError(f"Invalid attribute {kk} for {this_cls.__name__}!") + raise AttributeError(f"Invalid attribute {kk} for {this_cls.__name__}!") setattr(self, kk, vv) - if not cls.__dict__.get('__str__'): + if cls.__dict__.get('__str__') is None: @functools.wraps(Singleton.__str__) def __str__(self): return f"<{type(self).__name__} singleton instance>" - if not cls.__dict__.get('__repr__'): + if cls.__dict__.get('__repr__') is None: @functools.wraps(Singleton.__repr__) def __repr__(self): return f"<{type(self).__name__} singleton instance at {hex(id(self))}>" @@ -141,7 +144,7 @@ def __getattribute__(self, name): if not hasattr(this_cls, '_singleton_instance') \ or not super().__getattribute__('_valid'): raise RuntimeError(f"This instance of the singleton {this_cls.__name__} " - + "has been invalidated!") + + "has been invalidated!") return super().__getattribute__(name) @classmethod From 86e673831ff2b6fc901c788da9aba2bb83c19e51 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Thu, 23 Jan 2025 03:13:12 +0100 Subject: [PATCH 20/26] Deep rewrite of ClassProperty. Better documentation, simplified metaclass. Added ClassPropertyAccessor for introspection and _classproperty_dependencies to protect depencies which typically are regular class attributes. Still an issue with inheritance: infinite recursion because of using super() in de metaclass (happens when class is instantiated that is a child of a class with class properties). --- xaux/tools/class_property.py | 323 +++++++++++++++++++++++++++++++---- 1 file changed, 289 insertions(+), 34 deletions(-) diff --git a/xaux/tools/class_property.py b/xaux/tools/class_property.py index 84dee61..65ea57e 100644 --- a/xaux/tools/class_property.py +++ b/xaux/tools/class_property.py @@ -3,61 +3,126 @@ # Copyright (c) CERN, 2025. # # ######################################### # - -class ClassPropertyMeta(type): - def __setattr__(cls, key, value): - # Check if the attribute is a ClassProperty - for parent in cls.__mro__: - if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): - return parent.__dict__[key].__set__(cls, value) - return super(ClassPropertyMeta, cls).__setattr__(key, value) +import functools class ClassProperty: + """Descriptor to define class properties. + Similar to the built-in property, but for classes instead of instances. + - Contrary to a regular property, a __set__ or __delete__ call on the + owner class would not be intercepted. For this reason, it is necessary + to use a dedicated metaclass, the ClassPropertyMeta, to intercept these + calls, even when no setter or deleter is defined (as otherwise the + attribute would not be read-only and could still be overwritten). + - The ClassProperty class keeps a registry of ClassProperties for each + class, which is accessible with the 'get_properties' method. + - Whenever a class has ClassProperties, a ClassPropertyAccessor named + 'classproperty' will be attached to it, providing an attribute-like + interface to the ClassProperty attributes of a class for introspection. + Use like ?MyClass.classproperty.my_class_property to get the introspect. + - _classproperty_dependencies + + Example usage: + + class MyClass(metaclass=ClassPropertyMeta): + _classproperty_dependencies = { + '_my_classproperty': 0 + } + + @ClassProperty + def my_class_property(cls): + return cls._my_classproperty + + @my_class_property.setter + def my_class_property(cls, value): + cls._my_classproperty = value + + @my_class_property.deleter + def my_class_property(cls): + cls._my_classproperty = 0 + """ + _registry = {} # Registry to store ClassProperty names for each class @classmethod def get_properties(cls, owner, parents=True): + """Return the ClassProperty attributes of a class, optionally including those of + its parents.""" if not parents: - return cls._registry.get(owner, []) + return cls._registry.get(owner, {}) else: - return [prop for parent in owner.__mro__ - for prop in cls._registry.get(parent, [])] + return {name: prop for parent in owner.__mro__ + for name, prop in cls._registry.get(parent, {}).items()} + + def __repr__(self): + """Return repr(self).""" + return f"" def __init__(self, fget=None, fset=None, fdel=None, doc=None): - functools.update_wrapper(self, fget) - self.fget = fget - self.fset = fset - self.fdel = fdel + """Initialize self. See help(type(self)) for accurate signature.""" + self._fget = fget + self._fset = fset + self._fdel = fdel if doc is None and fget is not None: doc = fget.__doc__ self.__doc__ = doc def __set_name__(self, owner, name): + """Method to set name of a ClassProperty. Also asserts that the correct metaclass + is used, adds the property to the registry, and creates default getter, setter, and + deleter functions.""" self.name = name - # Verify that the class is a subclass of ClassPropertyMeta - if ClassPropertyMeta not in type(owner).__mro__: - raise AttributeError(f"Class `{owner.__name__}` must be have ClassPropertyMeta " - + f"as a metaclass to be able to use ClassProperties!") + self.owner = owner + # Check if we have the correct metaclass + self._assert_metaclass() # Add the property name to the registry for the class if owner not in ClassProperty._registry: - ClassProperty._registry[owner] = [] - ClassProperty._registry[owner].append(name) + ClassProperty._registry[owner] = {} + ClassProperty._registry[owner][name] = self # Create default getter, setter, and deleter if self.fget is None: def _getter(*args, **kwargs): - raise AttributeError(f"Unreadable attribute '{name}' of {owner.__name__} class!") - self.fget = _getter + raise AttributeError(f"Unreadable attribute '{name}' of {owner.__name__} " + + "class!") + self._fget = _getter if self.fset is None: def _setter(self, *args, **kwargs): - raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no setter") - self.fset = _setter + raise AttributeError(f"ClassProperty '{name}' of '{owner.__name__}' class " + + "has no setter") + self._fset = _setter if self.fdel is None: def _deleter(*args, **kwargs): - raise AttributeError(f"ClassProperty '{name}' of {owner.__name__} class has no deleter") - self.fdel = _deleter + raise AttributeError(f"ClassProperty '{name}' of '{owner.__name__}' class " + + "has no deleter") + self._fdel = _deleter + # Attach an accessor to the parent class to inspect ClassProperties + if not 'classproperty' in owner.__dict__: + owner.classproperty = ClassPropertyAccessor() + elif not isinstance(owner.__dict__['classproperty'], ClassPropertyAccessor): + raise TypeError(f"Class '{owner.__name__}' already has an attribute 'classproperty' " + + f"of type {type(owner.__dict__['classproperty']).__name__}! This is " + + "incompatible with the ClassProperty descriptor.") + + @property + def fget(self): + """Return the getter function of the ClassProperty.""" + # The fget function can only be set at initialisation, or with the getter method. + return self._fget + + @property + def fset(self): + """Return the setter function of the ClassProperty.""" + # The fset function can only be set at initialisation, or with the setter method. + return self._fset + + @property + def fdel(self): + """Return the deleter function of the ClassProperty.""" + # The fdel function can only be set at initialisation, or with the deleter method. + return self._fdel def __get__(self, instance, owner): + """Return an attribute of owner.""" if owner is None: owner = type(instance) try: @@ -66,20 +131,210 @@ def __get__(self, instance, owner): # Return a fallback if initialisation fails return None - def __set__(self, cls, value): - self.fset(cls, value) + def __set__(self, owner, value): + """Set a class attribute of owner to value.""" + self.fset(owner, value) - def __delete__(self, instance): - self.fdel(instance.__class__) + def __delete__(self, owner): + """Delete an attribute of owner.""" + self.fdel(owner) def getter(self, fget): - self.fget = fget + """Decorator to set the ClassProperty's getter fget.""" + self._fget = fget return self def setter(self, fset): - self.fset = fset + """Decorator to set the ClassProperty's setter fset. Need ClassPropertyMeta for this.""" + # We check the metaclass, even though this is already done in __set_name__, for the + # rare edge case where one would call the setter method directly after the class + # definition. + self._assert_metaclass() + self._fset = fset return self def deleter(self, fdel): - self.fdel = fdel + """Decorator to set the ClassProperty's deleter fdel. Need ClassPropertyMeta for this.""" + self._assert_metaclass() + self._fdel = fdel return self + + def _assert_metaclass(self): + # Verify that the metaclass is a subclass of ClassPropertyMeta, needed for fset and fdel + if hasattr(self, 'owner'): + if ClassPropertyMeta not in type(self.owner).__mro__: + raise TypeError(f"Class '{self.owner.__name__}' must have ClassPropertyMeta " + + f"as a metaclass to be able to use ClassProperties!") + + +class ClassPropertyMeta(type): + """Metaclass for classes with ClassProperty attributes. + This metaclass intercepts __setattr__ and __delattr__ calls, such that + setting or deleting a ClassProperty attribute on the class itself works + as expected. Both functions are defined at the metaclass level (to be + able to intercept calls to the class properties), but are also defined/ + overwritten at the class level to be able to intercept the same calls + but on instances. + """ + + def __new__(cls, name, bases, data, new_class=None): + """Define the new class. By allowing the new_class argument, we can + easily combine the metaclass with other metaclasses that define the + __new__ method. + """ + + if new_class is None: + new_class = type.__new__(cls, name, bases, data) + + # Overwrite the __setattr__ method in the class + original_setattr = new_class.__dict__.get('__setattr__', None) + def __setattr__(self, key, value): + """The __setattr__ method is expanded by the ClassProperty metaclass.""" + # This is the __setattr__ method when called on an INSTANCE of the class. + + this_cls = type(self) + # Check if the attribute is a ClassProperty + for parent in this_cls.__mro__: + if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): + return parent.__dict__[key].__set__(this_cls, value) + # If not, call the original __setattr__ method. + # However, there is still a potential issue. If we are setting a regular class + # attribute (like the underscore variable that goes with the ClassProperty), + # we need to set it on the class (this_cls) instead of on this instance (self); + # otherwise the attribute would be created newly on the instance (desynced from + # the class attribute). So we check if this attribute exists in the class dict. + # If yes, call the metaclass __setattr__ method to set it on the class. If not, + # call the original __setattr__ method to set it on the instance. + # Final caveat: if the attribute is a descriptor (like a property), we DO want + # to set it on the instance (as this is how descriptors are implemented). We + # can recognise this by checking if the attribute has a __get__ method. + if key in this_cls.__dict__ and not hasattr(this_cls.__dict__[key], '__get__'): + # Set the attribute on the class + return super(ClassPropertyMeta, this_cls).__setattr__(key, value) + else: + # Set the attribute on the instance + if original_setattr is not None: + return original_setattr(self, key, value) + else: + return super(this_cls, self).__setattr__(key, value) + new_class.__setattr__ = functools.wraps(ClassPropertyMeta.__setattr__)(__setattr__) + + # Overwrite the __delattr__ method in the class + original_delattr = new_class.__dict__.get('__delattr__', None) + def __delattr__(self, key): + """The __delattr__ method is expanded by the ClassProperty metaclass.""" + # This is the __delattr__ method when called on an INSTANCE of the class. + + this_cls = type(self) + # Check if the attribute is a ClassProperty + for parent in this_cls.__mro__: + if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): + return parent.__dict__[key].__delete__(this_cls) + # If not, call the original __delattr__ method. + # The logic here is the same as in the __setattr__ method above. + if key in this_cls.__dict__ and not hasattr(this_cls.__dict__[key], '__get__'): + return super(ClassPropertyMeta, this_cls).__delattr__(key) + else: + if original_delattr is not None: + return original_delattr(self, key) + else: + return super(this_cls, self).__delattr__(key) + new_class.__delattr__ = functools.wraps(ClassPropertyMeta.__delattr__)(__delattr__) + + # Get all dependencies that are used by the ClassProperties from the parents into the class + for parent in new_class.__mro__: + if '_classproperty_dependencies' in parent.__dict__: + for key, value in parent.__dict__['_classproperty_dependencies'].items(): + setattr(new_class, key, value) + + return new_class + + # Define __setattr__ at the metaclass level to intercept setter calls on the class + def __setattr__(cls, key, value): + """Set an attribute on the instance or the class, handling ClassProperty if needed.""" + # This is the __setattr__ method when called on the class ITSELF (not an instance). + + # Check if the attribute is a ClassProperty + for parent in cls.__mro__: + if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): + return parent.__dict__[key].__set__(cls, value) + # If not, call the original __setattr__ method + return super(ClassPropertyMeta, cls).__setattr__(key, value) + + # Define __delattr__ at the metaclass level to intercept deleter calls on the class + def __delattr__(cls, key): + """Delete an attribute from the instance or the class, handling ClassProperty if needed.""" + # This is the __delattr__ method when called on the class ITSELF (not an instance). + + # Check if the attribute is a ClassProperty + for parent in cls.__mro__: + if key in parent.__dict__ and isinstance(parent.__dict__[key], ClassProperty): + return parent.__dict__[key].__delete__(cls) + # If not, call the original __delattr__ method + return super(ClassPropertyMeta, cls).__delattr__(key) + + +class ClassPropertyAccessor: + """Helper class to introspect the ClassProperty attributes of a class and + their docstrings.""" + + def __repr__(self): + """Return repr(self).""" + return f"" + + def __get__(self, instance, owner): + """Return a ClassPropertyDict object for the owner class.""" + return ClassPropertyDict(owner) + + +class ClassPropertyDict: + """Helper class to provide an attribute-like interface to the ClassProperty + attributes of a class. This way, one can do e.g. ?MyClass.classproperty.cprop1 + to get the introspect info of cprop1.""" + + def __init__(self, owner): + """Initialize self. See help(type(self)) for accurate signature.""" + self.owner = owner + self._cprops = ClassProperty.get_properties(owner, parents=True) + + @property + def names(self): + """Return the names of the ClassProperty attributes of the owner class.""" + return tuple(self._cprops.keys()) + + def __repr__(self): + """Return repr(self).""" + num_props = len(self) + props = "ClassProperties" if num_props != 1 else "ClassProperty" + return f"" + + def __iter__(self): + """Implement iter(self).""" + return self._cprops.__iter__() + + def keys(self): + """A set-like object providing a view on the ClassProperty names.""" + return self._cprops.keys() + + def values(self): + """A set-like object providing a view on the ClassProperties.""" + return self._cprops.values() + + def items(self): + """A set-like object providing a view on the ClassProperties and their names.""" + return self._cprops.items() + + def __len__(self): + """Return len(self).""" + return len(self._cprops) + + def __contains__(self, key): + """Return key in self.""" + return key in self._cprops + + def __getattr__(self, name): + """Access the ClassProperty attributes of the owner class.""" + if name not in self._cprops: + raise AttributeError(f"Class '{self.owner.__name__}' has no ClassProperty '{name}'") + return self._cprops.get(name) From 8bed5b9c24ea91510e9b1adae7e79cd049de549f Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Thu, 23 Jan 2025 23:56:44 +0100 Subject: [PATCH 21/26] Thorough test on ClassProperty. Still fails on the instances due to infinite recursion. WIP. --- tests/test_class_property.py | 1777 ++++++++++++++++++++++++++++++++++ 1 file changed, 1777 insertions(+) create mode 100644 tests/test_class_property.py diff --git a/tests/test_class_property.py b/tests/test_class_property.py new file mode 100644 index 0000000..1184878 --- /dev/null +++ b/tests/test_class_property.py @@ -0,0 +1,1777 @@ +# copyright ############################### # +# This file is part of the Xaux package. # +# Copyright (c) CERN, 2025. # +# ######################################### # + +import re +import pytest + +from xaux import ClassProperty, ClassPropertyMeta, singleton +from xaux.tools.class_property import ClassPropertyDict + + +# Class definitions +# ================= + +# We use a few test scenarios: +# ParentCp1(no ClassProperty) -> ChildCp1(with CP) -> GrandChildCp1(with extra CP) -> GreatGrandChildCp1(no extra CP) +# ParentCp2(with CP) -> ChildCp2(no extra CP) -> GrandChildCp2(with extra CP) -> GreatGrandChildCp2(no extra CP) +# ParentCp3(singleton, with CP) -> ChildCp3(no extra CP) -> GrandChildCp3(with extra CP) -> GreatGrandChildCp3(no extra CP) +# ParentCp4(singleton, no CP) -> ChildCp4(with CP) -> GrandChildCp4(with extra CP) -> GreatGrandChildCp4(no extra CP) +# ParentCp5(with CP) -> ChildCp5(singleton, no CP) -> GrandChildCp5(with extra CP) -> GreatGrandChildCp5(no extra CP) + +class ParentCp1: + """Test ParentCp1 class for ClassProperty.""" + + def __init__(self): + """ParentCp1 __init__ docstring.""" + self.prop_parent = True + + +class ChildCp1(ParentCp1, metaclass=ClassPropertyMeta): + """Test ChildCp1 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop1': 1, + '_cprop2': 2 + } + rcprop3 = 3 # regular class attribute + + def __init__(self): + """ChildCp1 __init__ docstring.""" + super().__init__() + self._prop1 = 10 + self._prop2 = 20 + self.rprop3 = 30 # regular instance attribute + + @ClassProperty + def cprop1(cls): + """First class property for ChildCp1.""" + return cls._cprop1 + + @ClassProperty + def cprop2(cls): + """Second class property for ChildCp1.""" + return cls._cprop2 + + @cprop2.setter + def cprop2(cls, value): + """Second class property for ChildCp1 (setter).""" + cls._cprop2 = value + + @cprop2.deleter + def cprop2(cls): + """Second class property for ChildCp1 (deleter).""" + cls._cprop2 = 2 + + @property + def prop1(self): + """First property for ChildCp1.""" + return self._prop1 + + @property + def prop2(self): + """Second property for ChildCp1.""" + return self._prop2 + + @prop2.setter + def prop2(self, value): + """Second property for ChildCp1 (setter).""" + self._prop2 = value + + @prop2.deleter + def prop2(self): + """Second property for ChildCp1 (deleter).""" + self._prop2 = 20 + + +class GrandChildCp1(ChildCp1): + """Test GrandChildCp1 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop4': -1, + '_cprop5': -2 + } + rcprop6 = -3 # regular class attribute + + def __init__(self): + """GrandChildCp1 __init__ docstring.""" + super().__init__() + self._prop4 = -10 + self._prop5 = -20 + self.rprop6 = -30 # regular instance attribute + + @ClassProperty + def cprop4(cls): + """Fourth class property for GrandChildCp1.""" + return cls._cprop4 + + @ClassProperty + def cprop5(cls): + """Fifth class property for GrandChildCp1.""" + return cls._cprop5 + + @cprop5.setter + def cprop5(cls, value): + """Fifth class property for GrandChildCp1 (setter).""" + cls._cprop5 = value + + @cprop5.deleter + def cprop5(cls): + """Fifth class property for GrandChildCp1 (deleter).""" + cls._cprop5 = -2 + + @property + def prop4(self): + """Fourth property for GrandChildCp1.""" + return self._prop4 + + @property + def prop5(self): + """Fifth property for GrandChildCp1.""" + return self._prop5 + + @prop5.setter + def prop5(self, value): + """Fifth property for GrandChildCp1 (setter).""" + self._prop5 = value + + @prop5.deleter + def prop5(self): + """Fifth property for GrandChildCp1 (deleter).""" + self._prop5 = -20 + + +class GreatGrandChildCp1(GrandChildCp1): + """Test GreatGrandChildCp1 class for ClassProperty.""" + + def __init__(self): + """GreatGrandChildCp1 __init__ docstring.""" + super().__init__() + self.prop_greatgrandchild = True + + +class ParentCp2(metaclass=ClassPropertyMeta): + """Test ParentCp2 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop1': 1, + '_cprop2': 2 + } + rcprop3 = 3 # regular class attribute + + def __init__(self): + """ParentCp2 __init__ docstring.""" + self._prop1 = 10 + self._prop2 = 20 + self.rprop3 = 30 # regular instance attribute + + @ClassProperty + def cprop1(cls): + """First class property for ParentCp2.""" + return cls._cprop1 + + @ClassProperty + def cprop2(cls): + """Second class property for ParentCp2.""" + return cls._cprop2 + + @cprop2.setter + def cprop2(cls, value): + """Second class property for ParentCp2 (setter).""" + cls._cprop2 = value + + @cprop2.deleter + def cprop2(cls): + """Second class property for ParentCp2 (deleter).""" + cls._cprop2 = 2 + + @property + def prop1(self): + """First property for ParentCp2.""" + return self._prop1 + + @property + def prop2(self): + """Second property for ParentCp2.""" + return self._prop2 + + @prop2.setter + def prop2(self, value): + """Second property for ParentCp2 (setter).""" + self._prop2 = value + + @prop2.deleter + def prop2(self): + """Second property for ParentCp2 (deleter).""" + self._prop2 = 20 + + +class ChildCp2(ParentCp2): + """Test ChildCp2 class for ClassProperty.""" + + def __init__(self): + """ChildCp2 __init__ docstring.""" + super().__init__() + self.prop_child = True + + +class GrandChildCp2(ChildCp2): + """Test GrandChildCp2 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop4': -1, + '_cprop5': -2 + } + rcprop6 = -3 # regular class attribute + + def __init__(self): + """GrandChildCp2 __init__ docstring.""" + super().__init__() + self._prop4 = -10 + self._prop5 = -20 + self.rprop6 = -30 # regular instance attribute + + @ClassProperty + def cprop4(cls): + """Fourth class property for GrandChildCp2.""" + return cls._cprop4 + + @ClassProperty + def cprop5(cls): + """Fifth class property for GrandChildCp2.""" + return cls._cprop5 + + @cprop5.setter + def cprop5(cls, value): + """Fifth class property for GrandChildCp2 (setter).""" + cls._cprop5 = value + + @cprop5.deleter + def cprop5(cls): + """Fifth class property for GrandChildCp2 (deleter).""" + cls._cprop5 = -2 + + @property + def prop4(self): + """Fourth property for GrandChildCp2.""" + return self._prop4 + + @property + def prop5(self): + """Fifth property for GrandChildCp2.""" + return self._prop5 + + @prop5.setter + def prop5(self, value): + """Fifth property for GrandChildCp2 (setter).""" + self._prop5 = value + + @prop5.deleter + def prop5(self): + """Fifth property for GrandChildCp2 (deleter).""" + self._prop5 = -20 + + +class GreatGrandChildCp2(GrandChildCp2): + """Test GreatGrandChildCp2 class for ClassProperty.""" + + def __init__(self): + """GreatGrandChildCp2 __init__ docstring.""" + super().__init__() + self.prop_greatgrandchild = True + + +@singleton +class ParentCp3(metaclass=ClassPropertyMeta): + """Test ParentCp3 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop1': 1, + '_cprop2': 2 + } + rcprop3 = 3 # regular class attribute + + def __init__(self): + """ParentCp3 __init__ docstring.""" + self._prop1 = 10 + self._prop2 = 20 + self.rprop3 = 30 # regular instance attribute + + @ClassProperty + def cprop1(cls): + """First class property for ParentCp3.""" + return cls._cprop1 + + @ClassProperty + def cprop2(cls): + """Second class property for ParentCp3.""" + return cls._cprop2 + + @cprop2.setter + def cprop2(cls, value): + """Second class property for ParentCp3 (setter).""" + cls._cprop2 = value + + @cprop2.deleter + def cprop2(cls): + """Second class property for ParentCp3 (deleter).""" + cls._cprop2 = 2 + + @property + def prop1(self): + """First property for ParentCp3.""" + return self._prop1 + + @property + def prop2(self): + """Second property for ParentCp3.""" + return self._prop2 + + @prop2.setter + def prop2(self, value): + """Second property for ParentCp3 (setter).""" + self._prop2 = value + + @prop2.deleter + def prop2(self): + """Second property for ParentCp3 (deleter).""" + self._prop2 = 20 + + +class ChildCp3(ParentCp3): + """Test ChildCp3 class for ClassProperty.""" + + def __init__(self): + """ChildCp3 __init__ docstring.""" + super().__init__() + self.prop_child = True + + +class GrandChildCp3(ChildCp3): + """Test GrandChildCp3 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop4': -1, + '_cprop5': -2 + } + rcprop6 = -3 # regular class attribute + + def __init__(self): + """GrandChildCp3 __init__ docstring.""" + super().__init__() + self._prop4 = -10 + self._prop5 = -20 + self.rprop6 = -30 # regular instance attribute + + @ClassProperty + def cprop4(cls): + """Fourth class property for GrandChildCp3.""" + return cls._cprop4 + + @ClassProperty + def cprop5(cls): + """Fifth class property for GrandChildCp3.""" + return cls._cprop5 + + @cprop5.setter + def cprop5(cls, value): + """Fifth class property for GrandChildCp3 (setter).""" + cls._cprop5 = value + + @cprop5.deleter + def cprop5(cls): + """Fifth class property for GrandChildCp3 (deleter).""" + cls._cprop5 = -2 + + @property + def prop4(self): + """Fourth property for GrandChildCp3.""" + return self._prop4 + + @property + def prop5(self): + """Fifth property for GrandChildCp3.""" + return self._prop5 + + @prop5.setter + def prop5(self, value): + """Fifth property for GrandChildCp3 (setter).""" + self._prop5 = value + + @prop5.deleter + def prop5(self): + """Fifth property for GrandChildCp3 (deleter).""" + self._prop5 = -20 + + +class GreatGrandChildCp3(GrandChildCp3): + """Test GreatGrandChildCp3 class for ClassProperty.""" + + def __init__(self): + """GreatGrandChildCp3 __init__ docstring.""" + super().__init__() + self.prop_greatgrandchild = True + + +@singleton +class ParentCp4: + """Test ParentCp4 class for ClassProperty.""" + + def __init__(self): + """ParentCp4 __init__ docstring.""" + self.prop_parent = True + + +class ChildCp4(ParentCp4, metaclass=ClassPropertyMeta): + """Test ChildCp4 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop1': 1, + '_cprop2': 2 + } + rcprop3 = 3 # regular class attribute + + def __init__(self): + """ChildCp4 __init__ docstring.""" + super().__init__() + self._prop1 = 10 + self._prop2 = 20 + self.rprop3 = 30 # regular instance attribute + + @ClassProperty + def cprop1(cls): + """First class property for ChildCp4.""" + return cls._cprop1 + + @ClassProperty + def cprop2(cls): + """Second class property for ChildCp4.""" + return cls._cprop2 + + @cprop2.setter + def cprop2(cls, value): + """Second class property for ChildCp4 (setter).""" + cls._cprop2 = value + + @cprop2.deleter + def cprop2(cls): + """Second class property for ChildCp4 (deleter).""" + cls._cprop2 = 2 + + @property + def prop1(self): + """First property for ChildCp4.""" + return self._prop1 + + @property + def prop2(self): + """Second property for ChildCp4.""" + return self._prop2 + + @prop2.setter + def prop2(self, value): + """Second property for ChildCp4 (setter).""" + self._prop2 = value + + @prop2.deleter + def prop2(self): + """Second property for ChildCp4 (deleter).""" + self._prop2 = 20 + + +class GrandChildCp4(ChildCp4): + """Test GrandChildCp4 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop4': -1, + '_cprop5': -2 + } + rcprop6 = -3 # regular class attribute + + def __init__(self): + """GrandChildCp4 __init__ docstring.""" + super().__init__() + self._prop4 = -10 + self._prop5 = -20 + self.rprop6 = -30 # regular instance attribute + + @ClassProperty + def cprop4(cls): + """Fourth class property for GrandChildCp4.""" + return cls._cprop4 + + @ClassProperty + def cprop5(cls): + """Fifth class property for GrandChildCp4.""" + return cls._cprop5 + + @cprop5.setter + def cprop5(cls, value): + """Fifth class property for GrandChildCp4 (setter).""" + cls._cprop5 = value + + @cprop5.deleter + def cprop5(cls): + """Fifth class property for GrandChildCp4 (deleter).""" + cls._cprop5 = -2 + + @property + def prop4(self): + """Fourth property for GrandChildCp4.""" + return self._prop4 + + @property + def prop5(self): + """Fifth property for GrandChildCp4.""" + return self._prop5 + + @prop5.setter + def prop5(self, value): + """Fifth property for GrandChildCp4 (setter).""" + self._prop5 = value + + @prop5.deleter + def prop5(self): + """Fifth property for GrandChildCp4 (deleter).""" + self._prop5 = -20 + + +class GreatGrandChildCp4(GrandChildCp4): + """Test GreatGrandChildCp4 class for ClassProperty.""" + + def __init__(self): + """GreatGrandChildCp4 __init__ docstring.""" + super().__init__() + self.prop_greatgrandchild = True + + +class ParentCp5(metaclass=ClassPropertyMeta): + """Test ParentCp5 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop1': 1, + '_cprop2': 2 + } + rcprop3 = 3 # regular class attribute + + def __init__(self): + """ParentCp5 __init__ docstring.""" + self._prop1 = 10 + self._prop2 = 20 + self.rprop3 = 30 # regular instance attribute + + @ClassProperty + def cprop1(cls): + """First class property for ParentCp5.""" + return cls._cprop1 + + @ClassProperty + def cprop2(cls): + """Second class property for ParentCp5.""" + return cls._cprop2 + + @cprop2.setter + def cprop2(cls, value): + """Second class property for ParentCp5 (setter).""" + cls._cprop2 = value + + @cprop2.deleter + def cprop2(cls): + """Second class property for ParentCp5 (deleter).""" + cls._cprop2 = 2 + + @property + def prop1(self): + """First property for ParentCp5.""" + return self._prop1 + + @property + def prop2(self): + """Second property for ParentCp5.""" + return self._prop2 + + @prop2.setter + def prop2(self, value): + """Second property for ParentCp5 (setter).""" + self._prop2 = value + + @prop2.deleter + def prop2(self): + """Second property for ParentCp5 (deleter).""" + self._prop2 = 20 + + +@singleton +class ChildCp5(ParentCp5): + """Test ChildCp5 class for ClassProperty.""" + + def __init__(self): + """ChildCp5 __init__ docstring.""" + super().__init__() + self.prop_child = True + + +class GrandChildCp5(ChildCp5): + """Test GrandChildCp5 class for ClassProperty.""" + + _classproperty_dependencies = { + '_cprop4': -1, + '_cprop5': -2 + } + rcprop6 = -3 # regular class attribute + + def __init__(self): + """GrandChildCp5 __init__ docstring.""" + super().__init__() + self._prop4 = -10 + self._prop5 = -20 + self.rprop6 = -30 # regular instance attribute + + @ClassProperty + def cprop4(cls): + """Fourth class property for GrandChildCp5.""" + return cls._cprop4 + + @ClassProperty + def cprop5(cls): + """Fifth class property for GrandChildCp5.""" + return cls._cprop5 + + @cprop5.setter + def cprop5(cls, value): + """Fifth class property for GrandChildCp5 (setter).""" + cls._cprop5 = value + + @cprop5.deleter + def cprop5(cls): + """Fifth class property for GrandChildCp5 (deleter).""" + cls._cprop5 = -2 + + @property + def prop4(self): + """Fourth property for GrandChildCp5.""" + return self._prop4 + + @property + def prop5(self): + """Fifth property for GrandChildCp5.""" + return self._prop5 + + @prop5.setter + def prop5(self, value): + """Fifth property for GrandChildCp5 (setter).""" + self._prop5 = value + + @prop5.deleter + def prop5(self): + """Fifth property for GrandChildCp5 (deleter).""" + self._prop5 = -20 + + +class GreatGrandChildCp5(GrandChildCp5): + """Test GreatGrandChildCp5 class for ClassProperty.""" + + def __init__(self): + """GreatGrandChildCp5 __init__ docstring.""" + super().__init__() + self.prop_greatgrandchild = True + + + +# Tests +# ===== + +def test_classproperty_1(): + # ParentCp1(no CP) -> ChildCp1(with CP) -> GrandChildCp1(with extra CP) -> GreatGrandChildCp1(no extra CP) + _unittest_classproperty_class(ParentCp1, None, None, is_singleton=False) + _unittest_classproperty_class(ChildCp1, ChildCp1, None, is_singleton=False) + _unittest_classproperty_class(GrandChildCp1, ChildCp1, GrandChildCp1, is_singleton=False) + _unittest_classproperty_class(GreatGrandChildCp1, ChildCp1, GrandChildCp1, is_singleton=False) + + +def test_classproperty_2(): + # ParentCp2(with CP) -> ChildCp2(no extra CP) -> GrandChildCp2(with extra CP) -> GreatGrandChildCp2(no extra CP) + _unittest_classproperty_class(ParentCp2, ParentCp2, None, is_singleton=False) + _unittest_classproperty_class(ChildCp2, ParentCp2, None, is_singleton=False) + _unittest_classproperty_class(GrandChildCp2, ParentCp2, GrandChildCp2,is_singleton=False) + _unittest_classproperty_class(GreatGrandChildCp2, ParentCp2, GrandChildCp2,is_singleton=False) + + +def test_classproperty_3(): + # ParentCp3(singleton, with CP) -> ChildCp3(no extra CP) -> GrandChildCp3(with extra CP) -> GreatGrandChildCp3(no extra CP) + _unittest_classproperty_class(ParentCp3, ParentCp3, None, is_singleton=True) + _unittest_classproperty_class(ChildCp3, ParentCp3, None, is_singleton=True) + _unittest_classproperty_class(GrandChildCp3, ParentCp3, GrandChildCp3, is_singleton=True) + _unittest_classproperty_class(GreatGrandChildCp3, ParentCp3, GrandChildCp3, is_singleton=True) + + +def test_classproperty_4(): + # ParentCp4(singleton, no CP) -> ChildCp4(with CP) -> GrandChildCp4(with extra CP) -> GreatGrandChildCp4(no extra CP) + _unittest_classproperty_class(ParentCp4, None, None, is_singleton=True) + _unittest_classproperty_class(ChildCp4, ChildCp4, None, is_singleton=True) + _unittest_classproperty_class(GrandChildCp4, ChildCp4, GrandChildCp4, is_singleton=True) + _unittest_classproperty_class(GreatGrandChildCp4, ChildCp4, GrandChildCp4, is_singleton=True) + + +def test_classproperty_5(): + # ParentCp5(with CP) -> ChildCp5(singleton, no CP) -> GrandChildCp5(with extra CP) -> GreatGrandChildCp5(no extra CP) + _unittest_classproperty_class(ParentCp5, ParentCp5, None, is_singleton=False) + _unittest_classproperty_class(ChildCp5, ParentCp5, None, is_singleton=True) + _unittest_classproperty_class(GrandChildCp5, ParentCp5, GrandChildCp5, is_singleton=True) + _unittest_classproperty_class(GreatGrandChildCp5, ParentCp5, GrandChildCp5, is_singleton=True) + + +def test_classproperty_instance_1(): + # ParentCp1(no CP) -> ChildCp1(with CP) -> GrandChildCp1(with extra CP) -> GreatGrandChildCp1(no extra CP) + _unittest_classproperty_instance(ParentCp1, None, None, is_singleton=False, p_prop=True) + _unittest_classproperty_instance(ChildCp1, ChildCp1, None, is_singleton=False, p_prop=True) + _unittest_classproperty_instance(GrandChildCp1, ChildCp1, GrandChildCp1, is_singleton=False, p_prop=True) + _unittest_classproperty_instance(GreatGrandChildCp1, ChildCp1, GrandChildCp1, is_singleton=False, p_prop=True, ggc_prop=True) + + +def test_classproperty_instance_2(): + # ParentCp2(with CP) -> ChildCp2(no extra CP) -> GrandChildCp2(with extra CP) -> GreatGrandChildCp2(no extra CP) + _unittest_classproperty_instance(ParentCp2, ParentCp2, None, is_singleton=False) + _unittest_classproperty_instance(ChildCp2, ParentCp2, None, is_singleton=False, c_prop=True) + _unittest_classproperty_instance(GrandChildCp2, ParentCp2, GrandChildCp2,is_singleton=False, c_prop=True) + _unittest_classproperty_instance(GreatGrandChildCp2, ParentCp2, GrandChildCp2,is_singleton=False, c_prop=True, ggc_prop=True) + + +def test_classproperty_instance_3(): + # ParentCp3(singleton, with CP) -> ChildCp3(no extra CP) -> GrandChildCp3(with extra CP) -> GreatGrandChildCp3(no extra CP) + _unittest_classproperty_instance(ParentCp3, ParentCp3, None, is_singleton=True) + _unittest_classproperty_instance(ChildCp3, ParentCp3, None, is_singleton=True, c_prop=True) + _unittest_classproperty_instance(GrandChildCp3, ParentCp3, GrandChildCp3, is_singleton=True, c_prop=True) + _unittest_classproperty_instance(GreatGrandChildCp3, ParentCp3, GrandChildCp3, is_singleton=True, c_prop=True, ggc_prop=True) + + +def test_classproperty_instance_4(): + # ParentCp4(singleton, no CP) -> ChildCp4(with CP) -> GrandChildCp4(with extra CP) -> GreatGrandChildCp4(no extra CP) + _unittest_classproperty_instance(ParentCp4, None, None, is_singleton=True, p_prop=True) + _unittest_classproperty_instance(ChildCp4, ChildCp4, None, is_singleton=True, p_prop=True) + _unittest_classproperty_instance(GrandChildCp4, ChildCp4, GrandChildCp4, is_singleton=True, p_prop=True) + _unittest_classproperty_instance(GreatGrandChildCp4, ChildCp4, GrandChildCp4, is_singleton=True, p_prop=True, ggc_prop=True) + + +def test_classproperty_instance_5(): + # ParentCp5(with CP) -> ChildCp5(singleton, no CP) -> GrandChildCp5(with extra CP) -> GreatGrandChildCp5(no extra CP) + _unittest_classproperty_instance(ParentCp5, ParentCp5, None, is_singleton=False) + _unittest_classproperty_instance(ChildCp5, ParentCp5, None, is_singleton=True, c_prop=True) + _unittest_classproperty_instance(GrandChildCp5, ParentCp5, GrandChildCp5, is_singleton=True, c_prop=True) + _unittest_classproperty_instance(GreatGrandChildCp5, ParentCp5, GrandChildCp5, is_singleton=True, c_prop=True, ggc_prop=True) + + +def test_classproperty_accessor(): + _unittest_accessor(ChildCp1) + _unittest_accessor(GrandChildCp1, True) + _unittest_accessor(GreatGrandChildCp1, True) + _unittest_accessor(ParentCp2) + _unittest_accessor(ChildCp2) + _unittest_accessor(GrandChildCp2, True) + _unittest_accessor(GreatGrandChildCp2, True) + _unittest_accessor(ParentCp3) + _unittest_accessor(ChildCp3) + _unittest_accessor(GrandChildCp3, True) + _unittest_accessor(GreatGrandChildCp3, True) + _unittest_accessor(ChildCp4) + _unittest_accessor(GrandChildCp4, True) + _unittest_accessor(GreatGrandChildCp4, True) + _unittest_accessor(ParentCp5) + _unittest_accessor(ChildCp5) + _unittest_accessor(GrandChildCp5, True) + _unittest_accessor(GreatGrandChildCp5, True) + + +def test_docstrings(): + assert ClassProperty.__doc__.startswith("Descriptor to define class properties.") + _unittest_docstring(ChildCp1, ChildCp1) + _unittest_docstring(GrandChildCp1, ChildCp1, GrandChildCp1) + _unittest_docstring(GreatGrandChildCp1, ChildCp1, GrandChildCp1) + _unittest_docstring(ParentCp2, ParentCp2) + _unittest_docstring(ChildCp2, ParentCp2) + _unittest_docstring(GrandChildCp2, ParentCp2, GrandChildCp2) + _unittest_docstring(GreatGrandChildCp2, ParentCp2, GrandChildCp2) + _unittest_docstring(ParentCp3, ParentCp3) + _unittest_docstring(ChildCp3, ParentCp3) + _unittest_docstring(GrandChildCp3, ParentCp3, GrandChildCp3) + _unittest_docstring(GreatGrandChildCp3, ParentCp3, GrandChildCp3) + _unittest_docstring(ChildCp4, ChildCp4) + _unittest_docstring(GrandChildCp4, ChildCp4, GrandChildCp4) + _unittest_docstring(GreatGrandChildCp4, ChildCp4, GrandChildCp4) + _unittest_docstring(ParentCp5, ParentCp5) + _unittest_docstring(ChildCp5, ParentCp5) + _unittest_docstring(GrandChildCp5, ParentCp5, GrandChildCp5) + _unittest_docstring(GreatGrandChildCp5, ParentCp5, GrandChildCp5) + + +def test_structure(): + with pytest.raises(RuntimeError, match=re.escape("Error calling __set_name__ on " + + "'ClassProperty' instance 'cprop' in 'FailingClass'")) as err: + class FailingClass: + _cprop = 1 + @ClassProperty + def cprop(cls): + return cls._cprop1 + + @cprop.setter + def cprop(cls): + return cls._cprop1 + + original_err = err.value.__cause__ + assert isinstance(original_err, TypeError) + assert re.search(str(original_err), "Class 'FailingClass' must have ClassPropertyMeta as a " + "metaclass to be able to use ClassProperties!") + + +def _get_flags_and_names(cls, first_set_from_class, second_set_from_class): + if first_set_from_class: + if '__original_nonsingleton_class__' in first_set_from_class.__dict__: + # The ClassProperty is attached to the original class, not the singleton class + first_set_origin = first_set_from_class.__original_nonsingleton_class__.__name__ + first_set_inherited = True # Properties are always inherited, from the original class + else: + first_set_origin = first_set_from_class.__name__ + first_set_inherited = cls != first_set_from_class + else: + first_set_origin = None + first_set_inherited = False + if second_set_from_class: + if '__original_nonsingleton_class__' in second_set_from_class.__dict__: + # The ClassProperty is attached to the original class, not the singleton class + second_set_origin = second_set_from_class.__original_nonsingleton_class__.__name__ + second_set_inherited = True # Properties are always inherited, from the original class + else: + second_set_origin = second_set_from_class.__name__ + second_set_inherited = cls != second_set_from_class + else: + second_set_origin = None + second_set_inherited = False + return first_set_origin, first_set_inherited, second_set_origin, second_set_inherited + + +def _unittest_classproperty_class(cls, first_set_from_class, second_set_from_class, is_singleton): + # This test checks the functionality of the ClassProperty on a given class, while asserting + # that regular class attributes, properties, and regular instance attributes are not affected. + + # Some flags and names + first_set_origin, first_set_inherited, second_set_origin, second_set_inherited = \ + _get_flags_and_names(cls, first_set_from_class, second_set_from_class) + + # Check singleton + if is_singleton: + assert hasattr(cls, '__original_nonsingleton_class__') + + # Assert all properties exist. + def check_existence(): + if first_set_from_class: + # First the ClassProperties: + assert hasattr(cls, 'cprop1') + assert hasattr(cls, 'cprop2') + assert hasattr(cls, 'rcprop3') # regular class attribute + assert hasattr(cls, '_cprop1') + assert hasattr(cls, '_cprop2') + assert '_cprop1' in cls.__dict__ + assert '_cprop2' in cls.__dict__ + if first_set_inherited: + assert 'cprop1' not in cls.__dict__ + assert 'cprop2' not in cls.__dict__ + assert 'rcprop3' not in cls.__dict__ # regular class attribute + else: + assert 'cprop1' in cls.__dict__ + assert 'cprop2' in cls.__dict__ + assert 'rcprop3' in cls.__dict__ # regular class attribute + # Then the properties: + assert hasattr(cls, 'prop1') + assert hasattr(cls, 'prop2') + assert not hasattr(cls, 'rprop3') # regular attribute + assert not hasattr(cls, '_prop1') + assert not hasattr(cls, '_prop2') + if first_set_inherited: + assert 'prop1' not in cls.__dict__ + assert 'prop2' not in cls.__dict__ + else: + assert 'prop1' in cls.__dict__ + assert 'prop2' in cls.__dict__ + if second_set_from_class: + # First the ClassProperties: + assert hasattr(cls, 'cprop4') + assert hasattr(cls, 'cprop5') + assert hasattr(cls, 'rcprop6') # regular class attribute + assert hasattr(cls, '_cprop4') + assert hasattr(cls, '_cprop5') + assert '_cprop4' in cls.__dict__ + assert '_cprop5' in cls.__dict__ + if second_set_inherited: + assert 'cprop4' not in cls.__dict__ + assert 'cprop5' not in cls.__dict__ + assert 'rcprop6' not in cls.__dict__ # regular class attribute + else: + assert 'cprop4' in cls.__dict__ + assert 'cprop5' in cls.__dict__ + assert 'rcprop6' in cls.__dict__ # regular class attribute + # Then the properties: + assert hasattr(cls, 'prop4') + assert hasattr(cls, 'prop5') + assert not hasattr(cls, 'rprop6') # regular attribute + assert not hasattr(cls, '_prop4') + assert not hasattr(cls, '_prop5') + if second_set_inherited: + assert 'prop4' not in cls.__dict__ + assert 'prop5' not in cls.__dict__ + else: + assert 'prop4' in cls.__dict__ + assert 'prop5' in cls.__dict__ + check_existence() + + # Test the getters + if first_set_from_class: + # First the ClassProperties: + assert cls.cprop1 == 1 + cls._cprop1 = 9 + assert cls.cprop1 == 9 + assert cls.cprop2 == 2 + cls._cprop2 = 8 + assert cls.cprop2 == 8 + # Then the regular class attributes: + assert cls.rcprop3 == 3 + if second_set_from_class: + # First the ClassProperties: + assert cls.cprop4 == -1 + cls._cprop4 = -9 + assert cls.cprop4 == -9 + assert cls.cprop5 == -2 + cls._cprop5 = -8 + assert cls.cprop5 == -8 + # Then the regular class attributes: + assert cls.rcprop6 == -3 + + # Test the setters + if first_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " + + f"'{first_set_origin}' class has no setter")): + cls.cprop1 = 7 + assert cls.cprop1 == 9 + assert cls._cprop1 == 9 + cls.cprop2 = 7 + assert cls.cprop2 == 7 + assert cls._cprop2 == 7 + # Then the regular class attributes: + if first_set_inherited: + assert 'rcprop3' not in cls.__dict__ + cls.rcprop3 = 6 # This creates a new rcprop3 and attaches it to cls if inherited + assert cls.rcprop3 == 6 + assert 'rcprop3' in cls.__dict__ + if second_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " + + f"'{second_set_origin}' class has no setter")): + cls.cprop4 = -7 + assert cls.cprop4 == -9 + assert cls._cprop4 == -9 + cls.cprop5 = -7 + assert cls.cprop5 == -7 + assert cls._cprop5 == -7 + # Then the regular class attributes: + if second_set_inherited: + assert 'rcprop6' not in cls.__dict__ + cls.rcprop6 = -6 # This creates a new rcprop6 and attaches it to cls if inherited + assert cls.rcprop6 == -6 + assert 'rcprop6' in cls.__dict__ + + # Test the deleters + if first_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " + + f"'{first_set_origin}' class has no deleter")): + del cls.cprop1 + assert hasattr(cls, 'cprop1') + assert hasattr(cls, '_cprop1') + assert cls.cprop1 == 9 + del cls.cprop2 + assert hasattr(cls, 'cprop2') + assert hasattr(cls, '_cprop2') + assert cls.cprop2 == 2 + # Then the regular class attributes: + del cls.rcprop3 + if first_set_inherited: + assert 'rcprop3' not in cls.__dict__ + assert cls.rcprop3 == 3 # This is the original rcprop3, inherited + with pytest.raises(AttributeError, match=re.escape("type object " + + f"'{cls.__name__}' has no attribute 'rcprop3'")): + del cls.rcprop3 # Cannot delete an inherited class property + else: + assert not hasattr(cls, 'rcprop3') + if second_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " + + f"'{second_set_origin}' class has no deleter")): + del cls.cprop4 + assert hasattr(cls, 'cprop4') + assert hasattr(cls, '_cprop4') + assert cls.cprop4 == -9 + del cls.cprop5 + assert hasattr(cls, 'cprop5') + assert hasattr(cls, '_cprop5') + assert cls.cprop5 == -2 + # Then the regular class attributes: + del cls.rcprop6 + if second_set_inherited: + assert 'rcprop6' not in cls.__dict__ + assert cls.rcprop6 == -3 # This is the original rcprop6, inherited + with pytest.raises(AttributeError, match=re.escape("type object " + + f"'{cls.__name__}' has no attribute 'rcprop6'")): + del cls.rcprop6 # Cannot delete an inherited class property + else: + assert not hasattr(cls, 'rcprop6') + + # Reset the properties to defaut values + if first_set_from_class: + cls._cprop1 = 1 + if second_set_from_class: + cls._cprop4 = -1 + if first_set_from_class and not first_set_inherited: + cls.rcprop3 = 3 + if second_set_from_class and not second_set_inherited: + cls.rcprop6 = -3 + + # Assert no properties got lost + check_existence() + + +def _unittest_classproperty_instance(cls, first_set_from_class, second_set_from_class, is_singleton, + p_prop=False, c_prop=False, ggc_prop=False): + # This test checks the functionality of the ClassProperty on a given instance, while asserting + # that regular class attributes, properties, and regular instance attributes are not affected. + + # Some flags and names + first_set_origin, first_set_inherited, second_set_origin, second_set_inherited = \ + _get_flags_and_names(cls, first_set_from_class, second_set_from_class) + + # Spawn the instances + instance1 = cls() + instance2 = cls() + + # Check singleton + if is_singleton: + assert hasattr(cls, '__original_nonsingleton_class__') + assert instance1 is instance2 + + # Assert all properties exist. + def check_existence(): + if first_set_from_class: + # First the ClassProperties: + assert hasattr(instance1, 'cprop1') + assert hasattr(instance1, 'cprop2') + assert hasattr(instance1, 'rcprop3') # regular class attribute + assert hasattr(instance1, '_cprop1') + assert hasattr(instance1, '_cprop2') + assert hasattr(instance2, 'cprop1') + assert hasattr(instance2, 'cprop2') + assert hasattr(instance2, 'rcprop3') # regular class attribute + assert hasattr(instance2, '_cprop1') + assert hasattr(instance2, '_cprop2') + # Then the properties: + assert hasattr(instance1, 'prop1') + assert hasattr(instance1, 'prop2') + assert hasattr(instance1, 'rprop3') # regular attribute + assert hasattr(instance1, '_prop1') + assert hasattr(instance1, '_prop2') + assert hasattr(instance2, 'prop1') + assert hasattr(instance2, 'prop2') + assert hasattr(instance2, 'rprop3') # regular attribute + assert hasattr(instance2, '_prop1') + assert hasattr(instance2, '_prop2') + if second_set_from_class: + # First the ClassProperties: + assert hasattr(instance1, 'cprop4') + assert hasattr(instance1, 'cprop5') + assert hasattr(instance1, 'rcprop6') # regular class attribute + assert hasattr(instance1, '_cprop4') + assert hasattr(instance1, '_cprop5') + assert hasattr(instance2, 'cprop4') + assert hasattr(instance2, 'cprop5') + assert hasattr(instance2, 'rcprop6') # regular class attribute + assert hasattr(instance2, '_cprop4') + assert hasattr(instance2, '_cprop5') + # Then the properties: + assert hasattr(instance1, 'prop4') + assert hasattr(instance1, 'prop5') + assert hasattr(instance1, 'rprop6') # regular attribute + assert hasattr(instance1, '_prop4') + assert hasattr(instance1, '_prop5') + assert hasattr(instance2, 'prop4') + assert hasattr(instance2, 'prop5') + assert hasattr(instance2, 'rprop6') # regular attribute + assert hasattr(instance2, '_prop4') + assert hasattr(instance2, '_prop5') + if p_prop: + assert hasattr(instance1,'prop_parent') + assert hasattr(instance2,'prop_parent') + if c_prop: + assert hasattr(instance1,'prop_child') + assert hasattr(instance2,'prop_child') + if ggc_prop: + assert hasattr(instance1,'prop_greatgrandchild') + assert hasattr(instance2,'prop_greatgrandchild') + check_existence() + + # Test the getters + if first_set_from_class: + # First the ClassProperties: + assert cls.cprop1 == 1 + assert instance1.cprop1 == 1 + assert instance2.cprop1 == 1 + instance1._cprop1 = 9 + assert cls.cprop1 == 9 + assert instance1.cprop1 == 9 + assert instance2.cprop1 == 9 + assert cls._cprop1 == 9 + assert instance1._cprop1 == 9 + assert instance2._cprop1 == 9 + assert cls.cprop2 == 2 + assert instance1.cprop2 == 2 + assert instance2.cprop2 == 2 + instance1._cprop2 = 8 + assert cls.cprop2 == 8 + assert instance1.cprop2 == 8 + assert instance2.cprop2 == 8 + assert cls._cprop2 == 8 + assert instance1._cprop2 == 8 + assert instance2._cprop2 == 8 + # Then the regular class attributes: + assert cls.rcprop3 == 3 + assert instance1.rcprop3 == 3 + assert instance2.rcprop3 == 3 + # Then the properties (to ensure they are not affected): + assert instance1.prop1 == 10 + assert instance2.prop1 == 10 + assert instance1._prop1 == 10 + assert instance2._prop1 == 10 + assert instance1.prop2 == 20 + assert instance2.prop2 == 20 + assert instance1._prop2 == 20 + assert instance2._prop2 == 20 + # Finally the regular properties: + assert instance1.rprop3 == 30 + assert instance2.rprop3 == 30 + if second_set_from_class: + # First the ClassProperties: + assert cls.cprop4 == -1 + assert instance1.cprop4 == -1 + assert instance2.cprop4 == -1 + instance1._cprop4 = -9 + assert cls.cprop4 == -9 + assert instance1.cprop4 == -9 + assert instance2.cprop4 == -9 + assert cls._cprop4 == -9 + assert instance1._cprop4 == -9 + assert instance2._cprop4 == -9 + assert cls.cprop5 == -2 + assert instance1.cprop5 == -2 + assert instance2.cprop5 == -2 + instance1._cprop5 = -8 + assert cls.cprop5 == -8 + assert instance1.cprop5 == -8 + assert instance2.cprop5 == -8 + assert cls._cprop5 == -8 + assert instance1._cprop5 == -8 + assert instance2._cprop5 == -8 + # Then the regular class attributes: + assert cls.rcprop6 == -3 + assert instance1.rcprop6 == -3 + assert instance2.rcprop6 == -3 + # Then the properties (to ensure they are not affected): + assert instance1.prop4 == -10 + assert instance2.prop4 == -10 + assert instance1._prop4 == -10 + assert instance2._prop4 == -10 + assert instance1.prop5 == -20 + assert instance2.prop5 == -20 + assert instance1._prop5 == -20 + assert instance2._prop5 == -20 + # Finally the regular properties: + assert instance1.rprop6 == -30 + assert instance2.rprop6 == -30 + if p_prop: + assert instance1.prop_parent is True + assert instance2.prop_parent is True + if c_prop: + assert instance1.prop_child is True + assert instance2.prop_child is True + if ggc_prop: + assert instance1.prop_greatgrandchild is True + assert instance2.prop_greatgrandchild is True + + # Test the setters + if first_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " + + f"'{first_set_origin}' class has no setter")): + instance1.cprop1 = 7 + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " + + f"'{first_set_origin}' class has no setter")): + instance2.cprop1 = 7 + assert cls.cprop1 == 9 + assert instance1.cprop1 == 9 + assert instance2.cprop1 == 9 + assert cls._cprop1 == 9 + assert instance1._cprop1 == 9 + assert instance2._cprop1 == 9 + cls.cprop2 = 7 + assert cls.cprop2 == 7 + assert instance1.cprop2 == 7 + assert instance2.cprop2 == 7 + assert cls._cprop2 == 7 + assert instance1._cprop2 == 7 + assert instance2._cprop2 == 7 + instance1.cprop2 = 6 + assert cls.cprop2 == 6 + assert instance1.cprop2 == 6 + assert instance2.cprop2 == 6 + assert cls._cprop2 == 6 + assert instance1._cprop2 == 6 + assert instance2._cprop2 == 6 + instance2.cprop2 = 5 + assert cls.cprop2 == 5 + assert instance1.cprop2 == 5 + assert instance2.cprop2 == 5 + assert cls._cprop2 == 5 + assert instance1._cprop2 == 5 + assert instance2._cprop2 == 5 + # Regular class attributes are counterintuitive on instances; do not test + # Then the properties (to ensure they are not affected): + with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " + + f"'{first_set_origin}' object has no setter")): + instance1.prop1 = 70 + with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " + + f"'{first_set_origin}' object has no setter")): + instance2.prop1 = 70 + assert instance1.prop1 == 10 + assert instance2.prop1 == 10 + assert instance1._prop1 == 10 + assert instance2._prop1 == 10 + instance1.prop2 = 70 + assert instance1.prop2 == 70 + assert instance1._prop2 == 70 + if is_singleton: + assert instance2.prop2 == 70 + assert instance2._prop2 == 70 + else: + assert instance2.prop2 == 20 + assert instance2._prop2 == 20 + instance2.prop2 = 50 + if is_singleton: + assert instance1.prop2 == 50 + assert instance1._prop2 == 50 + else: + assert instance1.prop2 == 70 + assert instance1._prop2 == 70 + assert instance2.prop2 == 50 + assert instance2._prop2 == 50 + # Then the regular properties: + instance1.rprop3 = 80 + assert instance1.rprop3 == 80 + if is_singleton: + assert instance2.rprop3 == 80 + else: + assert instance2.rprop3 == 30 + instance2.rprop3 = 70 + if is_singleton: + assert instance1.rprop3 == 70 + else: + assert instance1.rprop3 == 80 + assert instance2.rprop3 == 70 + if second_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " + + f"'{second_set_origin}' class has no setter")): + instance1.cprop4 = -7 + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " + + f"'{second_set_origin}' class has no setter")): + instance2.cprop4 = -7 + assert cls.cprop4 == -9 + assert instance1.cprop4 == -9 + assert instance2.cprop4 == -9 + assert cls._cprop4 == -9 + assert instance1._cprop4 == -9 + assert instance2._cprop4 == -9 + cls.cprop5 = -7 + assert cls.cprop5 == -7 + assert instance1.cprop5 == -7 + assert instance2.cprop5 == -7 + assert cls._cprop5 == -7 + assert instance1._cprop5 == -7 + assert instance2._cprop5 == -7 + instance1.cprop5 = -6 + assert cls.cprop5 == -6 + assert instance1.cprop5 == -6 + assert instance2.cprop5 == -6 + assert cls._cprop5 == -6 + assert instance1._cprop5 == -6 + assert instance2._cprop5 == -6 + instance2.cprop5 = -5 + assert cls.cprop5 == -5 + assert instance1.cprop5 == -5 + assert instance2.cprop5 == -5 + assert cls._cprop5 == -5 + assert instance1._cprop5 == -5 + assert instance2._cprop5 == -5 + # Regular class attributes are counterintuitive on instances; do not test + # Then the properties (to ensure they are not affected): + with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " + + f"'{second_set_origin}' object has no setter")): + instance1.prop4 = -70 + with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " + + f"'{second_set_origin}' object has no setter")): + instance2.prop4 = -70 + assert instance1.prop4 == -10 + assert instance2.prop4 == -10 + assert instance1._prop4 == -10 + assert instance2._prop4 == -10 + instance1.prop5 = -70 + assert instance1.prop5 == -70 + assert instance1._prop5 == -70 + if is_singleton: + assert instance2.prop5 == -70 + assert instance2._prop5 == -70 + else: + assert instance2.prop5 == -20 + assert instance2._prop5 == -20 + instance2.prop5 = -50 + if is_singleton: + assert instance1.prop5 == -50 + assert instance1._prop5 == -50 + else: + assert instance1.prop5 == -70 + assert instance1._prop5 == -70 + assert instance2.prop5 == -50 + assert instance2._prop5 == -50 + # Then the regular properties: + instance1.rprop6 = -80 + assert instance1.rprop6 == -80 + if is_singleton: + assert instance2.rprop6 == -80 + else: + assert instance2.rprop6 == -30 + instance2.rprop6 = -70 + if is_singleton: + assert instance1.rprop6 == -70 + else: + assert instance1.rprop6 == -80 + assert instance2.rprop6 == -70 + if p_prop: + assert instance1.prop_parent is True + assert instance2.prop_parent is True + instance1.prop_parent = False + assert instance1.prop_parent is False + if is_singleton: + assert instance2.prop_parent is False + else: + assert instance2.prop_parent is True + instance2.prop_parent = False + assert instance1.prop_parent is False + assert instance2.prop_parent is False + if c_prop: + assert instance1.prop_child is True + assert instance2.prop_child is True + instance1.prop_child = False + assert instance1.prop_child is False + if is_singleton: + assert instance2.prop_child is False + else: + assert instance2.prop_child is True + instance2.prop_child = False + assert instance1.prop_child is False + assert instance2.prop_child is False + if ggc_prop: + assert instance1.prop_greatgrandchild is True + assert instance2.prop_greatgrandchild is True + instance1.prop_greatgrandchild = False + assert instance1.prop_greatgrandchild is False + if is_singleton: + assert instance2.prop_greatgrandchild is False + else: + assert instance2.prop_greatgrandchild is True + instance2.prop_greatgrandchild = False + assert instance1.prop_greatgrandchild is False + assert instance2.prop_greatgrandchild is False + + # Test the deleters + if first_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " + + f"'{first_set_origin}' class has no deleter")): + del instance1.cprop1 + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " + + f"'{first_set_origin}' class has no deleter")): + del instance2.cprop1 + assert hasattr(cls, 'cprop1') + assert hasattr(instance1, 'cprop1') + assert hasattr(instance2, 'cprop1') + assert hasattr(cls, '_cprop1') + assert hasattr(instance1, '_cprop1') + assert hasattr(instance2, '_cprop1') + assert cls.cprop1 == 9 + assert instance1.cprop1 == 9 + assert instance2.cprop1 == 9 + assert cls._cprop1 == 9 + assert instance1._cprop1 == 9 + assert instance2._cprop1 == 9 + del cls.cprop2 + assert hasattr(cls, 'cprop2') + assert hasattr(instance1, 'cprop2') + assert hasattr(instance2, 'cprop2') + assert hasattr(cls, '_cprop2') + assert hasattr(instance1, '_cprop2') + assert hasattr(instance2, '_cprop2') + assert cls.cprop2 == 2 + assert instance1.cprop2 == 2 + assert instance2.cprop2 == 2 + assert cls._cprop2 == 2 + assert instance1._cprop2 == 2 + assert instance2._cprop2 == 2 + cls.cprop2 = 5 # reset + assert cls.cprop2 == 5 + del instance1.cprop2 + assert hasattr(cls, 'cprop2') + assert hasattr(instance1, 'cprop2') + assert hasattr(instance2, 'cprop2') + assert hasattr(cls, '_cprop2') + assert hasattr(instance1, '_cprop2') + assert hasattr(instance2, '_cprop2') + assert cls.cprop2 == 2 + assert instance1.cprop2 == 2 + assert instance2.cprop2 == 2 + assert cls._cprop2 == 2 + assert instance1._cprop2 == 2 + assert instance2._cprop2 == 2 + cls.cprop2 = 5 # reset + assert cls.cprop2 == 5 + del instance2.cprop2 + assert hasattr(cls, 'cprop2') + assert hasattr(instance1, 'cprop2') + assert hasattr(instance2, 'cprop2') + assert hasattr(cls, '_cprop2') + assert hasattr(instance1, '_cprop2') + assert hasattr(instance2, '_cprop2') + assert cls.cprop2 == 2 + assert instance1.cprop2 == 2 + assert instance2.cprop2 == 2 + assert cls._cprop2 == 2 + assert instance1._cprop2 == 2 + assert instance2._cprop2 == 2 + cls.cprop2 = 5 # reset + assert cls.cprop2 == 5 + # Regular class attributes are counterintuitive on instances; do not test + # Then on properties (to ensure they are not affected): + with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " + + f"'{first_set_origin}' object has no deleter")): + del instance1.prop1 + with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " + + f"'{first_set_origin}' object has no deleter")): + del instance2.prop1 + assert hasattr(instance1, 'prop1') + assert hasattr(instance2, 'prop1') + assert hasattr(instance1, '_prop1') + assert hasattr(instance2, '_prop1') + assert instance1.prop1 == 10 + assert instance2.prop1 == 10 + del instance1.prop2 + assert hasattr(instance1, 'prop2') + assert hasattr(instance2, 'prop2') + assert hasattr(instance1, '_prop2') + assert hasattr(instance2, '_prop2') + assert instance1.prop2 == 20 + assert instance1._prop2 == 20 + if is_singleton: + assert instance2.prop2 == 20 + assert instance2._prop2 == 20 + else: + assert instance2.prop2 == 50 + assert instance2._prop2 == 50 + instance1.prop2 = 70 # reset + del instance2.prop2 + assert hasattr(instance1, 'prop2') + assert hasattr(instance2, 'prop2') + assert hasattr(instance1, '_prop2') + assert hasattr(instance2, '_prop2') + if is_singleton: + assert instance1.prop2 == 20 + assert instance1._prop2 == 20 + else: + assert instance1.prop2 == 70 + assert instance1._prop2 == 70 + assert instance2._prop2 == 20 + assert instance2._prop2 == 20 + # Finally on regular properties: + del instance1.rprop3 + assert not hasattr(instance1, 'rprop3') + if is_singleton: + assert not hasattr(instance2, 'rprop3') + else: + assert hasattr(instance2, 'rprop3') + instance1.rprop3 = 30 # reset + del instance2.rprop3 + if is_singleton: + assert not hasattr(instance1, 'rprop3') + else: + assert hasattr(instance1, 'rprop3') + assert not hasattr(instance2, 'rprop3') + instance2.rprop3 = 30 # reset + if second_set_from_class: + # First the ClassProperties: + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " + + f"'{second_set_origin}' class has no deleter")): + del instance1.cprop4 + with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " + + f"'{second_set_origin}' class has no deleter")): + del instance2.cprop4 + assert hasattr(cls, 'cprop4') + assert hasattr(instance1, 'cprop4') + assert hasattr(instance2, 'cprop4') + assert hasattr(cls, '_cprop4') + assert hasattr(instance1, '_cprop4') + assert hasattr(instance2, '_cprop4') + assert cls.cprop4 == 9 + assert instance1.cprop4 == 9 + assert instance2.cprop4 == 9 + assert cls._cprop4 == 9 + assert instance1._cprop4 == 9 + assert instance2._cprop4 == 9 + del cls.cprop5 + assert hasattr(cls, 'cprop5') + assert hasattr(instance1, 'cprop5') + assert hasattr(instance2, 'cprop5') + assert hasattr(cls, '_cprop5') + assert hasattr(instance1, '_cprop5') + assert hasattr(instance2, '_cprop5') + assert cls.cprop5 == 2 + assert instance1.cprop5 == 2 + assert instance2.cprop5 == 2 + assert cls._cprop5 == 2 + assert instance1._cprop5 == 2 + assert instance2._cprop5 == 2 + cls.cprop5 = 5 # reset + assert cls.cprop5 == 5 + del instance1.cprop5 + assert hasattr(cls, 'cprop5') + assert hasattr(instance1, 'cprop5') + assert hasattr(instance2, 'cprop5') + assert hasattr(cls, '_cprop5') + assert hasattr(instance1, '_cprop5') + assert hasattr(instance2, '_cprop5') + assert cls.cprop5 == 2 + assert instance1.cprop5 == 2 + assert instance2.cprop5 == 2 + assert cls._cprop5 == 2 + assert instance1._cprop5 == 2 + assert instance2._cprop5 == 2 + cls.cprop5 = 5 # reset + assert cls.cprop5 == 5 + del instance2.cprop5 + assert hasattr(cls, 'cprop5') + assert hasattr(instance1, 'cprop5') + assert hasattr(instance2, 'cprop5') + assert hasattr(cls, '_cprop5') + assert hasattr(instance1, '_cprop5') + assert hasattr(instance2, '_cprop5') + assert cls.cprop5 == 2 + assert instance1.cprop5 == 2 + assert instance2.cprop5 == 2 + assert cls._cprop5 == 2 + assert instance1._cprop5 == 2 + assert instance2._cprop5 == 2 + cls.cprop5 = 5 # reset + assert cls.cprop5 == 5 + # Regular class attributes are counterintuitive on instances; do not test + # Then on properties (to ensure they are not affected): + with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " + + f"'{second_set_origin}' object has no deleter")): + del instance1.prop4 + with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " + + f"'{second_set_origin}' object has no deleter")): + del instance2.prop4 + assert hasattr(instance1, 'prop4') + assert hasattr(instance2, 'prop4') + assert hasattr(instance1, '_prop4') + assert hasattr(instance2, '_prop4') + assert instance1.prop4 == -10 + assert instance2.prop4 == -10 + del instance1.prop5 + assert hasattr(instance1, 'prop5') + assert hasattr(instance2, 'prop5') + assert hasattr(instance1, '_prop5') + assert hasattr(instance2, '_prop5') + assert instance1.prop5 == -20 + assert instance1._prop5 == -20 + if is_singleton: + assert instance2.prop5 == -20 + assert instance2._prop5 == -20 + else: + assert instance2.prop5 == -50 + assert instance2._prop5 == -50 + instance1.prop5 = -70 # reset + del instance2.prop5 + assert hasattr(instance1, 'prop5') + assert hasattr(instance2, 'prop5') + assert hasattr(instance1, '_prop5') + assert hasattr(instance2, '_prop5') + if is_singleton: + assert instance1.prop5 == -20 + assert instance1._prop5 == -20 + else: + assert instance1.prop5 == -70 + assert instance1._prop5 == -70 + assert instance2._prop5 == -20 + assert instance2._prop5 == -20 + # Finally on regular properties: + del instance1.rprop6 + assert not hasattr(instance1, 'rprop6') + if is_singleton: + assert not hasattr(instance2, 'rprop6') + else: + assert hasattr(instance2, 'rprop6') + instance1.rprop6 = -30 # reset + del instance2.rprop6 + if is_singleton: + assert not hasattr(instance1, 'rprop6') + else: + assert hasattr(instance1, 'rprop6') + assert not hasattr(instance2, 'rprop6') + instance2.rprop6 = -30 # reset + if p_prop: + del instance1.prop_parent + assert not hasattr(instance1, 'prop_parent') + if is_singleton: + assert not hasattr(instance2, 'prop_parent') + else: + assert hasattr(instance2, 'prop_parent') + instance1.prop_parent = True # reset + del instance2.prop_parent + if is_singleton: + assert not hasattr(instance1, 'prop_parent') + else: + assert hasattr(instance1, 'prop_parent') + assert not hasattr(instance2, 'prop_parent') + instance2.prop_parent = True # reset + if c_prop: + del instance1.prop_child + assert not hasattr(instance1, 'prop_child') + if is_singleton: + assert not hasattr(instance2, 'prop_child') + else: + assert hasattr(instance2, 'prop_child') + instance1.prop_child = True # reset + del instance2.prop_child + if is_singleton: + assert not hasattr(instance1, 'prop_child') + else: + assert hasattr(instance1, 'prop_child') + assert not hasattr(instance2, 'prop_child') + instance2.prop_child = True # reset + if ggc_prop: + del instance1.prop_greatgrandchild + assert not hasattr(instance1, 'prop_greatgrandchild') + if is_singleton: + assert not hasattr(instance2, 'prop_greatgrandchild') + else: + assert hasattr(instance2, 'prop_greatgrandchild') + instance1.prop_greatgrandchild = True # reset + del instance2.prop_greatgrandchild + if is_singleton: + assert not hasattr(instance1, 'prop_greatgrandchild') + else: + assert hasattr(instance1, 'prop_greatgrandchild') + assert not hasattr(instance2, 'prop_greatgrandchild') + instance2.prop_greatgrandchild = True # reset + + # Reset the class properties to defaut values (we don't care about the instance + # properties, as the instances are discarded after this test) + if first_set_from_class: + cls._cprop1 = 1 + if second_set_from_class: + cls._cprop4 = -1 + if first_set_from_class and not first_set_inherited: + cls.rcprop3 = 3 + if second_set_from_class and not second_set_inherited: + cls.rcprop6 = -3 + + # Assert no properties got lost + check_existence() + + +def _unittest_accessor(cls, has_second_set=False): + assert isinstance(cls.classproperty, ClassPropertyDict) + if has_second_set: + assert len(cls.classproperty) == 4 + assert len(cls.classproperty.names) == 4 + else: + assert len(cls.classproperty) == 2 + assert len(cls.classproperty.names) == 2 + assert 'cprop1' in cls.classproperty + assert 'cprop1' in cls.classproperty.names + assert isinstance(cls.classproperty.cprop1, ClassProperty) + assert 'cprop2' in cls.classproperty + assert 'cprop2' in cls.classproperty.names + assert isinstance(cls.classproperty.cprop2, ClassProperty) + if has_second_set: + assert 'cprop4' in cls.classproperty + assert 'cprop4' in cls.classproperty.names + assert isinstance(cls.classproperty.cprop4, ClassProperty) + assert 'cprop5' in cls.classproperty + assert 'cprop5' in cls.classproperty.names + assert isinstance(cls.classproperty.cprop5, ClassProperty) + for prop in cls.classproperty.values(): + assert isinstance(prop, ClassProperty) + for prop in cls.classproperty.keys(): + if has_second_set: + assert prop in ['cprop1', 'cprop2', 'cprop4', 'cprop5'] + else: + assert prop in ['cprop1', 'cprop2'] + for name, prop in cls.classproperty.items(): + assert isinstance(prop, ClassProperty) + if has_second_set: + assert name in ['cprop1', 'cprop2', 'cprop4', 'cprop5'] + else: + assert name in ['cprop1', 'cprop2'] + + +def _unittest_docstring(cls, first_set_from_class, second_set_from_class=None): + first_set_from_class = first_set_from_class.__name__ + if second_set_from_class: + second_set_from_class = second_set_from_class.__name__ + + assert cls.__doc__ == f"Test {cls.__name__} class for ClassProperty." + assert cls.__init__.__doc__ == f"{cls.__name__} __init__ docstring." + + # Workaround for ClassProperty introspection + assert cls.classproperty.cprop1.__doc__ == f"First class property for {first_set_from_class}." + assert cls.classproperty.cprop1.fget.__doc__ == f"First class property for {first_set_from_class}." + assert cls.classproperty.cprop2.__doc__ == f"Second class property for {first_set_from_class}." + assert cls.classproperty.cprop2.fget.__doc__ == f"Second class property for {first_set_from_class}." + assert cls.classproperty.cprop2.fset.__doc__ == f"Second class property for {first_set_from_class} (setter)." + assert cls.classproperty.cprop2.fdel.__doc__ == f"Second class property for {first_set_from_class} (deleter)." + if second_set_from_class: + assert cls.classproperty.cprop4.__doc__ == f"Fourth class property for {second_set_from_class}." + assert cls.classproperty.cprop4.fget.__doc__ == f"Fourth class property for {second_set_from_class}." + assert cls.classproperty.cprop5.__doc__ == f"Fifth class property for {second_set_from_class}." + assert cls.classproperty.cprop5.fget.__doc__ == f"Fifth class property for {second_set_from_class}." + assert cls.classproperty.cprop5.fset.__doc__ == f"Fifth class property for {second_set_from_class} (setter)." + assert cls.classproperty.cprop5.fdel.__doc__ == f"Fifth class property for {second_set_from_class} (deleter)." + + # Normal property introspection + assert cls.prop1.__doc__ == f"First property for {first_set_from_class}." + assert cls.prop1.fget.__doc__ == f"First property for {first_set_from_class}." + assert cls.prop2.__doc__ == f"Second property for {first_set_from_class}." + assert cls.prop2.fget.__doc__ == f"Second property for {first_set_from_class}." + assert cls.prop2.fset.__doc__ == f"Second property for {first_set_from_class} (setter)." + assert cls.prop2.fdel.__doc__ == f"Second property for {first_set_from_class} (deleter)." + if second_set_from_class: + assert cls.prop4.__doc__ == f"Fourth property for {second_set_from_class}." + assert cls.prop4.fget.__doc__ == f"Fourth property for {second_set_from_class}." + assert cls.prop5.__doc__ == f"Fifth property for {second_set_from_class}." + assert cls.prop5.fget.__doc__ == f"Fifth property for {second_set_from_class}." + assert cls.prop5.fset.__doc__ == f"Fifth property for {second_set_from_class} (setter)." + assert cls.prop5.fdel.__doc__ == f"Fifth property for {second_set_from_class} (deleter)." From ebb7853a68432e814b586499ee78f8fb09bd593d Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 24 Jan 2025 03:03:11 +0100 Subject: [PATCH 22/26] Fix in infinite loops in class property. --- tests/test_class_property.py | 130 +++++++++++++++++------------------ xaux/tools/class_property.py | 19 ++--- 2 files changed, 73 insertions(+), 76 deletions(-) diff --git a/tests/test_class_property.py b/tests/test_class_property.py index 1184878..fa2d546 100644 --- a/tests/test_class_property.py +++ b/tests/test_class_property.py @@ -824,30 +824,24 @@ def cprop(cls): "metaclass to be able to use ClassProperties!") -def _get_flags_and_names(cls, first_set_from_class, second_set_from_class): +def _get_inherited_flag(cls, first_set_from_class, second_set_from_class): if first_set_from_class: if '__original_nonsingleton_class__' in first_set_from_class.__dict__: - # The ClassProperty is attached to the original class, not the singleton class - first_set_origin = first_set_from_class.__original_nonsingleton_class__.__name__ - first_set_inherited = True # Properties are always inherited, from the original class + # Properties are always inherited, from the original class above the singleton + first_set_inherited = True else: - first_set_origin = first_set_from_class.__name__ first_set_inherited = cls != first_set_from_class else: - first_set_origin = None first_set_inherited = False if second_set_from_class: if '__original_nonsingleton_class__' in second_set_from_class.__dict__: - # The ClassProperty is attached to the original class, not the singleton class - second_set_origin = second_set_from_class.__original_nonsingleton_class__.__name__ - second_set_inherited = True # Properties are always inherited, from the original class + # Properties are always inherited, from the original class above the singleton + second_set_inherited = True else: - second_set_origin = second_set_from_class.__name__ second_set_inherited = cls != second_set_from_class else: - second_set_origin = None second_set_inherited = False - return first_set_origin, first_set_inherited, second_set_origin, second_set_inherited + return first_set_inherited, second_set_inherited def _unittest_classproperty_class(cls, first_set_from_class, second_set_from_class, is_singleton): @@ -855,8 +849,8 @@ def _unittest_classproperty_class(cls, first_set_from_class, second_set_from_cla # that regular class attributes, properties, and regular instance attributes are not affected. # Some flags and names - first_set_origin, first_set_inherited, second_set_origin, second_set_inherited = \ - _get_flags_and_names(cls, first_set_from_class, second_set_from_class) + first_set_inherited, second_set_inherited = \ + _get_inherited_flag(cls, first_set_from_class, second_set_from_class) # Check singleton if is_singleton: @@ -950,7 +944,7 @@ def check_existence(): if first_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{first_set_origin}' class has no setter")): + + f"'{cls.__name__}' class has no setter")): cls.cprop1 = 7 assert cls.cprop1 == 9 assert cls._cprop1 == 9 @@ -966,7 +960,7 @@ def check_existence(): if second_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{second_set_origin}' class has no setter")): + + f"'{cls.__name__}' class has no setter")): cls.cprop4 = -7 assert cls.cprop4 == -9 assert cls._cprop4 == -9 @@ -984,7 +978,7 @@ def check_existence(): if first_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{first_set_origin}' class has no deleter")): + + f"'{cls.__name__}' class has no deleter")): del cls.cprop1 assert hasattr(cls, 'cprop1') assert hasattr(cls, '_cprop1') @@ -999,14 +993,14 @@ def check_existence(): assert 'rcprop3' not in cls.__dict__ assert cls.rcprop3 == 3 # This is the original rcprop3, inherited with pytest.raises(AttributeError, match=re.escape("type object " - + f"'{cls.__name__}' has no attribute 'rcprop3'")): + + f"'{cls.__name__}' has no attribute 'rcprop3'")): del cls.rcprop3 # Cannot delete an inherited class property else: assert not hasattr(cls, 'rcprop3') if second_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{second_set_origin}' class has no deleter")): + + f"'{cls.__name__}' class has no deleter")): del cls.cprop4 assert hasattr(cls, 'cprop4') assert hasattr(cls, '_cprop4') @@ -1021,7 +1015,7 @@ def check_existence(): assert 'rcprop6' not in cls.__dict__ assert cls.rcprop6 == -3 # This is the original rcprop6, inherited with pytest.raises(AttributeError, match=re.escape("type object " - + f"'{cls.__name__}' has no attribute 'rcprop6'")): + + f"'{cls.__name__}' has no attribute 'rcprop6'")): del cls.rcprop6 # Cannot delete an inherited class property else: assert not hasattr(cls, 'rcprop6') @@ -1046,8 +1040,8 @@ def _unittest_classproperty_instance(cls, first_set_from_class, second_set_from_ # that regular class attributes, properties, and regular instance attributes are not affected. # Some flags and names - first_set_origin, first_set_inherited, second_set_origin, second_set_inherited = \ - _get_flags_and_names(cls, first_set_from_class, second_set_from_class) + first_set_inherited, second_set_inherited = \ + _get_inherited_flag(cls, first_set_from_class, second_set_from_class) # Spawn the instances instance1 = cls() @@ -1208,10 +1202,10 @@ def check_existence(): if first_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{first_set_origin}' class has no setter")): + + f"'{cls.__name__}' class has no setter")): instance1.cprop1 = 7 with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{first_set_origin}' class has no setter")): + + f"'{cls.__name__}' class has no setter")): instance2.cprop1 = 7 assert cls.cprop1 == 9 assert instance1.cprop1 == 9 @@ -1243,10 +1237,10 @@ def check_existence(): # Regular class attributes are counterintuitive on instances; do not test # Then the properties (to ensure they are not affected): with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{first_set_origin}' object has no setter")): + + f"'{cls.__name__}' object has no setter")): instance1.prop1 = 70 with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{first_set_origin}' object has no setter")): + + f"'{cls.__name__}' object has no setter")): instance2.prop1 = 70 assert instance1.prop1 == 10 assert instance2.prop1 == 10 @@ -1286,10 +1280,10 @@ def check_existence(): if second_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{second_set_origin}' class has no setter")): + + f"'{cls.__name__}' class has no setter")): instance1.cprop4 = -7 with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{second_set_origin}' class has no setter")): + + f"'{cls.__name__}' class has no setter")): instance2.cprop4 = -7 assert cls.cprop4 == -9 assert instance1.cprop4 == -9 @@ -1321,10 +1315,10 @@ def check_existence(): # Regular class attributes are counterintuitive on instances; do not test # Then the properties (to ensure they are not affected): with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{second_set_origin}' object has no setter")): + + f"'{cls.__name__}' object has no setter")): instance1.prop4 = -70 with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{second_set_origin}' object has no setter")): + + f"'{cls.__name__}' object has no setter")): instance2.prop4 = -70 assert instance1.prop4 == -10 assert instance2.prop4 == -10 @@ -1402,10 +1396,10 @@ def check_existence(): if first_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{first_set_origin}' class has no deleter")): + + f"'{cls.__name__}' class has no deleter")): del instance1.cprop1 with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{first_set_origin}' class has no deleter")): + + f"'{cls.__name__}' class has no deleter")): del instance2.cprop1 assert hasattr(cls, 'cprop1') assert hasattr(instance1, 'cprop1') @@ -1467,10 +1461,10 @@ def check_existence(): # Regular class attributes are counterintuitive on instances; do not test # Then on properties (to ensure they are not affected): with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{first_set_origin}' object has no deleter")): + + f"'{cls.__name__}' object has no deleter")): del instance1.prop1 with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{first_set_origin}' object has no deleter")): + + f"'{cls.__name__}' object has no deleter")): del instance2.prop1 assert hasattr(instance1, 'prop1') assert hasattr(instance2, 'prop1') @@ -1523,10 +1517,10 @@ def check_existence(): if second_set_from_class: # First the ClassProperties: with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{second_set_origin}' class has no deleter")): + + f"'{cls.__name__}' class has no deleter")): del instance1.cprop4 with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{second_set_origin}' class has no deleter")): + + f"'{cls.__name__}' class has no deleter")): del instance2.cprop4 assert hasattr(cls, 'cprop4') assert hasattr(instance1, 'cprop4') @@ -1534,12 +1528,12 @@ def check_existence(): assert hasattr(cls, '_cprop4') assert hasattr(instance1, '_cprop4') assert hasattr(instance2, '_cprop4') - assert cls.cprop4 == 9 - assert instance1.cprop4 == 9 - assert instance2.cprop4 == 9 - assert cls._cprop4 == 9 - assert instance1._cprop4 == 9 - assert instance2._cprop4 == 9 + assert cls.cprop4 == -9 + assert instance1.cprop4 == -9 + assert instance2.cprop4 == -9 + assert cls._cprop4 == -9 + assert instance1._cprop4 == -9 + assert instance2._cprop4 == -9 del cls.cprop5 assert hasattr(cls, 'cprop5') assert hasattr(instance1, 'cprop5') @@ -1547,14 +1541,14 @@ def check_existence(): assert hasattr(cls, '_cprop5') assert hasattr(instance1, '_cprop5') assert hasattr(instance2, '_cprop5') - assert cls.cprop5 == 2 - assert instance1.cprop5 == 2 - assert instance2.cprop5 == 2 - assert cls._cprop5 == 2 - assert instance1._cprop5 == 2 - assert instance2._cprop5 == 2 - cls.cprop5 = 5 # reset - assert cls.cprop5 == 5 + assert cls.cprop5 == -2 + assert instance1.cprop5 == -2 + assert instance2.cprop5 == -2 + assert cls._cprop5 == -2 + assert instance1._cprop5 == -2 + assert instance2._cprop5 == -2 + cls.cprop5 = -5 # reset + assert cls.cprop5 == -5 del instance1.cprop5 assert hasattr(cls, 'cprop5') assert hasattr(instance1, 'cprop5') @@ -1562,14 +1556,14 @@ def check_existence(): assert hasattr(cls, '_cprop5') assert hasattr(instance1, '_cprop5') assert hasattr(instance2, '_cprop5') - assert cls.cprop5 == 2 - assert instance1.cprop5 == 2 - assert instance2.cprop5 == 2 - assert cls._cprop5 == 2 - assert instance1._cprop5 == 2 - assert instance2._cprop5 == 2 - cls.cprop5 = 5 # reset - assert cls.cprop5 == 5 + assert cls.cprop5 == -2 + assert instance1.cprop5 == -2 + assert instance2.cprop5 == -2 + assert cls._cprop5 == -2 + assert instance1._cprop5 == -2 + assert instance2._cprop5 == -2 + cls.cprop5 = -5 # reset + assert cls.cprop5 == -5 del instance2.cprop5 assert hasattr(cls, 'cprop5') assert hasattr(instance1, 'cprop5') @@ -1577,21 +1571,21 @@ def check_existence(): assert hasattr(cls, '_cprop5') assert hasattr(instance1, '_cprop5') assert hasattr(instance2, '_cprop5') - assert cls.cprop5 == 2 - assert instance1.cprop5 == 2 - assert instance2.cprop5 == 2 - assert cls._cprop5 == 2 - assert instance1._cprop5 == 2 - assert instance2._cprop5 == 2 - cls.cprop5 = 5 # reset - assert cls.cprop5 == 5 + assert cls.cprop5 == -2 + assert instance1.cprop5 == -2 + assert instance2.cprop5 == -2 + assert cls._cprop5 == -2 + assert instance1._cprop5 == -2 + assert instance2._cprop5 == -2 + cls.cprop5 = -5 # reset + assert cls.cprop5 == -5 # Regular class attributes are counterintuitive on instances; do not test # Then on properties (to ensure they are not affected): with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{second_set_origin}' object has no deleter")): + + f"'{cls.__name__}' object has no deleter")): del instance1.prop4 with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{second_set_origin}' object has no deleter")): + + f"'{cls.__name__}' object has no deleter")): del instance2.prop4 assert hasattr(instance1, 'prop4') assert hasattr(instance2, 'prop4') diff --git a/xaux/tools/class_property.py b/xaux/tools/class_property.py index 65ea57e..c122387 100644 --- a/xaux/tools/class_property.py +++ b/xaux/tools/class_property.py @@ -81,18 +81,18 @@ def __set_name__(self, owner, name): ClassProperty._registry[owner][name] = self # Create default getter, setter, and deleter if self.fget is None: - def _getter(*args, **kwargs): - raise AttributeError(f"Unreadable attribute '{name}' of {owner.__name__} " + def _getter(this_owner): + raise AttributeError(f"Unreadable attribute '{name}' of {this_owner.__name__} " + "class!") self._fget = _getter if self.fset is None: - def _setter(self, *args, **kwargs): - raise AttributeError(f"ClassProperty '{name}' of '{owner.__name__}' class " + def _setter(this_owner, value): + raise AttributeError(f"ClassProperty '{name}' of '{this_owner.__name__}' class " + "has no setter") self._fset = _setter if self.fdel is None: - def _deleter(*args, **kwargs): - raise AttributeError(f"ClassProperty '{name}' of '{owner.__name__}' class " + def _deleter(this_owner): + raise AttributeError(f"ClassProperty '{name}' of '{this_owner.__name__}' class " + "has no deleter") self._fdel = _deleter # Attach an accessor to the parent class to inspect ClassProperties @@ -216,7 +216,10 @@ def __setattr__(self, key, value): if original_setattr is not None: return original_setattr(self, key, value) else: - return super(this_cls, self).__setattr__(key, value) + # We have to call super on new_class, i.e. the class this method is + # attached to. Otherwise we will end up in infinite loops in case of + # inheritance. + return super(new_class, self).__setattr__(key, value) new_class.__setattr__ = functools.wraps(ClassPropertyMeta.__setattr__)(__setattr__) # Overwrite the __delattr__ method in the class @@ -238,7 +241,7 @@ def __delattr__(self, key): if original_delattr is not None: return original_delattr(self, key) else: - return super(this_cls, self).__delattr__(key) + return super(new_class, self).__delattr__(key) new_class.__delattr__ = functools.wraps(ClassPropertyMeta.__delattr__)(__delattr__) # Get all dependencies that are used by the ClassProperties from the parents into the class From 3a79bdec1e0ca27fc7a4abbba359b64ffac1c007 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 24 Jan 2025 03:23:42 +0100 Subject: [PATCH 23/26] Made class property test compatible with older python versions. --- tests/test_class_property.py | 111 +++++++++++++++++++---------------- xaux/tools/singleton.py | 1 - 2 files changed, 61 insertions(+), 51 deletions(-) diff --git a/tests/test_class_property.py b/tests/test_class_property.py index fa2d546..9158f56 100644 --- a/tests/test_class_property.py +++ b/tests/test_class_property.py @@ -4,6 +4,7 @@ # ######################################### # import re +import sys import pytest from xaux import ClassProperty, ClassPropertyMeta, singleton @@ -806,8 +807,15 @@ def test_docstrings(): def test_structure(): - with pytest.raises(RuntimeError, match=re.escape("Error calling __set_name__ on " - + "'ClassProperty' instance 'cprop' in 'FailingClass'")) as err: + if sys.version_info >= (3, 12): + err = TypeError + regex = "Class 'FailingClass' must have ClassPropertyMeta as a metaclass " \ + + "to be able to use ClassProperties!" + else: + err = RuntimeError + regex = "Error calling __set_name__ on 'ClassProperty' instance 'cprop' in 'FailingClass'" + + with pytest.raises(err, match=re.escape(regex)) as err: class FailingClass: _cprop = 1 @ClassProperty @@ -818,10 +826,11 @@ def cprop(cls): def cprop(cls): return cls._cprop1 - original_err = err.value.__cause__ - assert isinstance(original_err, TypeError) - assert re.search(str(original_err), "Class 'FailingClass' must have ClassPropertyMeta as a " - "metaclass to be able to use ClassProperties!") + if sys.version_info < (3, 12): + original_err = err.value.__cause__ + assert isinstance(original_err, TypeError) + assert re.search(str(original_err), "Class 'FailingClass' must have ClassPropertyMeta " + + "as a metaclass to be able to use ClassProperties!") def _get_inherited_flag(cls, first_set_from_class, second_set_from_class): @@ -943,8 +952,8 @@ def check_existence(): # Test the setters if first_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{cls.__name__}' class has no setter")): + regex = f"ClassProperty 'cprop1' of '{cls.__name__}' class has no setter" + with pytest.raises(AttributeError, match=re.escape(regex)): cls.cprop1 = 7 assert cls.cprop1 == 9 assert cls._cprop1 == 9 @@ -959,8 +968,8 @@ def check_existence(): assert 'rcprop3' in cls.__dict__ if second_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{cls.__name__}' class has no setter")): + regex = f"ClassProperty 'cprop4' of '{cls.__name__}' class has no setter" + with pytest.raises(AttributeError, match=re.escape(regex)): cls.cprop4 = -7 assert cls.cprop4 == -9 assert cls._cprop4 == -9 @@ -977,8 +986,8 @@ def check_existence(): # Test the deleters if first_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{cls.__name__}' class has no deleter")): + regex = f"ClassProperty 'cprop1' of '{cls.__name__}' class has no deleter" + with pytest.raises(AttributeError, match=re.escape(regex)): del cls.cprop1 assert hasattr(cls, 'cprop1') assert hasattr(cls, '_cprop1') @@ -992,15 +1001,14 @@ def check_existence(): if first_set_inherited: assert 'rcprop3' not in cls.__dict__ assert cls.rcprop3 == 3 # This is the original rcprop3, inherited - with pytest.raises(AttributeError, match=re.escape("type object " - + f"'{cls.__name__}' has no attribute 'rcprop3'")): + with pytest.raises(AttributeError): del cls.rcprop3 # Cannot delete an inherited class property else: assert not hasattr(cls, 'rcprop3') if second_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{cls.__name__}' class has no deleter")): + regex = f"ClassProperty 'cprop4' of '{cls.__name__}' class has no deleter" + with pytest.raises(AttributeError, match=re.escape(regex)): del cls.cprop4 assert hasattr(cls, 'cprop4') assert hasattr(cls, '_cprop4') @@ -1014,8 +1022,7 @@ def check_existence(): if second_set_inherited: assert 'rcprop6' not in cls.__dict__ assert cls.rcprop6 == -3 # This is the original rcprop6, inherited - with pytest.raises(AttributeError, match=re.escape("type object " - + f"'{cls.__name__}' has no attribute 'rcprop6'")): + with pytest.raises(AttributeError): del cls.rcprop6 # Cannot delete an inherited class property else: assert not hasattr(cls, 'rcprop6') @@ -1201,11 +1208,10 @@ def check_existence(): # Test the setters if first_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{cls.__name__}' class has no setter")): + regex = f"ClassProperty 'cprop1' of '{cls.__name__}' class has no setter" + with pytest.raises(AttributeError, match=re.escape(regex)): instance1.cprop1 = 7 - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{cls.__name__}' class has no setter")): + with pytest.raises(AttributeError, match=re.escape(regex)): instance2.cprop1 = 7 assert cls.cprop1 == 9 assert instance1.cprop1 == 9 @@ -1236,11 +1242,13 @@ def check_existence(): assert instance2._cprop2 == 5 # Regular class attributes are counterintuitive on instances; do not test # Then the properties (to ensure they are not affected): - with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{cls.__name__}' object has no setter")): + if sys.version_info >= (3, 11): + regex = f"property 'prop1' of '{cls.__name__}' object has no setter" + else: + regex = "can't set attribute" + with pytest.raises(AttributeError, match=re.escape(regex)): instance1.prop1 = 70 - with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{cls.__name__}' object has no setter")): + with pytest.raises(AttributeError, match=re.escape(regex)): instance2.prop1 = 70 assert instance1.prop1 == 10 assert instance2.prop1 == 10 @@ -1279,11 +1287,10 @@ def check_existence(): assert instance2.rprop3 == 70 if second_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{cls.__name__}' class has no setter")): + regex = f"ClassProperty 'cprop4' of '{cls.__name__}' class has no setter" + with pytest.raises(AttributeError, match=re.escape(regex)): instance1.cprop4 = -7 - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{cls.__name__}' class has no setter")): + with pytest.raises(AttributeError, match=re.escape(regex)): instance2.cprop4 = -7 assert cls.cprop4 == -9 assert instance1.cprop4 == -9 @@ -1314,11 +1321,13 @@ def check_existence(): assert instance2._cprop5 == -5 # Regular class attributes are counterintuitive on instances; do not test # Then the properties (to ensure they are not affected): - with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{cls.__name__}' object has no setter")): + if sys.version_info >= (3, 11): + regex = f"property 'prop4' of '{cls.__name__}' object has no setter" + else: + regex = "can't set attribute" + with pytest.raises(AttributeError, match=re.escape(regex)): instance1.prop4 = -70 - with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{cls.__name__}' object has no setter")): + with pytest.raises(AttributeError, match=re.escape(regex)): instance2.prop4 = -70 assert instance1.prop4 == -10 assert instance2.prop4 == -10 @@ -1395,11 +1404,10 @@ def check_existence(): # Test the deleters if first_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{cls.__name__}' class has no deleter")): + regex = f"ClassProperty 'cprop1' of '{cls.__name__}' class has no deleter" + with pytest.raises(AttributeError, match=re.escape(regex)): del instance1.cprop1 - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop1' of " - + f"'{cls.__name__}' class has no deleter")): + with pytest.raises(AttributeError, match=re.escape(regex)): del instance2.cprop1 assert hasattr(cls, 'cprop1') assert hasattr(instance1, 'cprop1') @@ -1460,11 +1468,13 @@ def check_existence(): assert cls.cprop2 == 5 # Regular class attributes are counterintuitive on instances; do not test # Then on properties (to ensure they are not affected): - with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{cls.__name__}' object has no deleter")): + if sys.version_info >= (3, 11): + regex = f"property 'prop1' of '{cls.__name__}' object has no deleter" + else: + regex = "can't delete attribute" + with pytest.raises(AttributeError, match=re.escape(regex)): del instance1.prop1 - with pytest.raises(AttributeError, match=re.escape("property 'prop1' of " - + f"'{cls.__name__}' object has no deleter")): + with pytest.raises(AttributeError, match=re.escape(regex)): del instance2.prop1 assert hasattr(instance1, 'prop1') assert hasattr(instance2, 'prop1') @@ -1516,11 +1526,10 @@ def check_existence(): instance2.rprop3 = 30 # reset if second_set_from_class: # First the ClassProperties: - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{cls.__name__}' class has no deleter")): + regex = f"ClassProperty 'cprop4' of '{cls.__name__}' class has no deleter" + with pytest.raises(AttributeError, match=re.escape(regex)): del instance1.cprop4 - with pytest.raises(AttributeError, match=re.escape("ClassProperty 'cprop4' of " - + f"'{cls.__name__}' class has no deleter")): + with pytest.raises(AttributeError, match=re.escape(regex)): del instance2.cprop4 assert hasattr(cls, 'cprop4') assert hasattr(instance1, 'cprop4') @@ -1581,11 +1590,13 @@ def check_existence(): assert cls.cprop5 == -5 # Regular class attributes are counterintuitive on instances; do not test # Then on properties (to ensure they are not affected): - with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{cls.__name__}' object has no deleter")): + if sys.version_info >= (3, 11): + regex = f"property 'prop4' of '{cls.__name__}' object has no deleter" + else: + regex = "can't delete attribute" + with pytest.raises(AttributeError, match=re.escape(regex)): del instance1.prop4 - with pytest.raises(AttributeError, match=re.escape("property 'prop4' of " - + f"'{cls.__name__}' object has no deleter")): + with pytest.raises(AttributeError, match=re.escape(regex)): del instance2.prop4 assert hasattr(instance1, 'prop4') assert hasattr(instance2, 'prop4') diff --git a/xaux/tools/singleton.py b/xaux/tools/singleton.py index 02106a5..99e3cc6 100644 --- a/xaux/tools/singleton.py +++ b/xaux/tools/singleton.py @@ -3,7 +3,6 @@ # Copyright (c) CERN, 2025. # # ######################################### # -import sys import functools from .function_tools import count_required_arguments From 3632c400e42cdb69aa45c1f4c88bcf05063d3aa1 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 24 Jan 2025 04:41:23 +0100 Subject: [PATCH 24/26] Updated hash test --- tests/test_general_tools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_general_tools.py b/tests/test_general_tools.py index 25359a4..4686b2c 100644 --- a/tests/test_general_tools.py +++ b/tests/test_general_tools.py @@ -152,6 +152,6 @@ def test_system_lock(): def test_hash(): - hs = get_hash('test_singleton.py') - assert hs == '2d4af659d0fdd2779492ffb937e4d04c9eac3bae0c02678297a83077f9548c5001a8b47'\ - + '71512a02b85628cf5736ebbf7d30071031deb79aa5a391283a093ea4a' + hs = get_hash('cronjob_example.py') + assert hs == '3eeb344d1236d3d0e2400744c732aded84528a4491600b5533052ced14b03fc5249668' \ + + '3d2f5e71ac18f4ddf14673a4b53fb06c01c95f1a1d0ea11a485439a17b' From d11fbeeb344b1353a0b0493acef3c51e59ab69c1 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 24 Jan 2025 04:50:23 +0100 Subject: [PATCH 25/26] Updated documentation --- README.md | 70 +++++++++++++++++++++++++++++++++++- xaux/dev_tools/__init__.py | 7 +++- xaux/tools/class_property.py | 10 +++++- xaux/tools/singleton.py | 4 +-- 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ebe1d87..b30eed3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,71 @@ # Xaux -Support tools for Xsuite packages +Xaux is a package with support tools, both for general usage and tune for CERN / Xsuite usage. It is thoroughly tested for all python versions from 3.8 onwards, to ensure stability. The following tools are provided: + +### Singleton +The decorator `@singleton` will redefine a class into a singleton such that only one instance exists and the same instance is returned every time the class is instantiated. + +- This is implemented by letting the class inherit from itself and renaming it, as this was the cleanest way to ensure inheritance compatibility. Each child of a singleton class will be its own singleton. +- Each re-initialisation of the singleton will keep the class attributes, and for this reason, the `__init__` method should not have any required arguments. This is asserted at the class creation time. +- By default, the singleton allows setting private attributes in the constructor, but this can be overridden by setting `allow_underscore_vars_in_init=False`. +- The singleton can be reset by calling the `delete()` method of the class, which will invalidate any existing instances. +- The decorator provides a `get_self()` method, which is a class method that is more relaxed than the constructor, as it allows passing any `**kwargs` even if they aren't attributes for the singleton (these will then just be ignored). This is useful for kwargs filtering in getters or specific functions. + +### Class Property +The descriptor `@ClassProperty` works similar as `@property` but is used to define class properties (instead of instance properties). + +- Contrary to a regular `property`, a `__set__` or `__delete__` call on the owner class would not be intercepted because of how Python classes work. For this reason, it is necessary to use a dedicated metaclass, the `ClassPropertyMeta`, to intercept these calls, even when no setter or deleter is defined (as otherwise the attribute would not be read-only and could still be overwritten). +- The descriptor class keeps a registry of all `ClassProperty` attributes for each class, which is accessible with the `get_properties()` method. +- Whenever a class has a `ClassProperty`, a `ClassPropertyAccessor` named `classproperty` will be attached to it, providing an attribute-like interface to the `ClassProperty` attributes of a class for introspection. Use it as `?MyClass.classproperty.my_class_property` to get the introspect in `IPython`. +- An important caveat is that regular class attributes do not always behave as expected when inherited, which might be an issue when a `ClassProperty` uses such a regular class attribute (for instance as the private attribute it is encapsulating). Indeed, when the parent has a class attribute `_prop` it will not be copied unto the child, and any `ClassProperty.setter` applied on the child will inevitably update the parent's attribute as well. To handle this, one can define a dict `_classproperty_dependencies` in the class to declare all dependent regular class attributes and their initial values. The `ClassPropertyMeta` then copies these attributes to the child. + +Example usage: + +```python +class MyClass(metaclass=ClassPropertyMeta): + _classproperty_dependencies = { + '_my_classproperty': 0 + } + + @ClassProperty + def my_class_property(cls): + return cls._my_classproperty + + @my_class_property.setter + def my_class_property(cls, value): + cls._my_classproperty = value + + @my_class_property.deleter + def my_class_property(cls): + cls._my_classproperty = 0 +``` + +### FsPath +This is an extension to the `Path` class from `pathlib`, which is adapted to work, besides on regular local file systems, robustly and efficiently on AFS (the Andrew File System) and EOS (a storage-oriented file system developed at CERN). It defines three classes, `LocalPath`, `AfsPath`, and `EosPath`, and a class factory `FsPath`. The correct class will be automatically instantiated based on the file system on which the path sits. Care is taken to correctly resolve a path when symlinks are present. + +The main advantage is that for a whole set of file operations, the standard `Path` implementations are overwritten by specific server commands native to `AFS` and `EOS`. This ensures that paths are always in sync with their server nodes. Furthermore, new methods are added to `FsPath` which are missing from `Path`. These are `getfid()`, `flush()`, `lexists()`, `is_broken_symlink()`, `rmtree()`, `copy_to()`, `move_to()`, and `size()`. + +Note that `LocalPath` is just a regular `Path` but with additional access to the `FsPath` methods. + +### General Tools +These are a set of lightweight tools: + - `timestamp` provides an easy way to get timestamps into logs and filenames (with second, millisecond, or microsecond accuracy). + - `ranID` generates a Base64 encoded random ID string, useful for in filenames or element names. + - `system_lock` is typically used for a cronjob. It will exit the python process if the previous cronjob did not yet finish (based on a custom lockfile name). + - `get_hash` is a quick way to hash a file, in chunks of a given size. + +Then there are also a few tools to get info about a function's arguments, which are only accessible via `xaux.tools` and are essentially just wrappers around functions in `inspect`. These are `count_arguments`, `count_required_arguments`, `count_optional_arguments`, `has_variable_length_arguments`, `has_variable_length_positional_arguments`, and `has_variable_length_keyword_arguments`. + +### ProtectFile +This is a wrapper around a file pointer, protecting it with a lockfile. It is meant to be used inside a context, where the entering and leaving of a context ensures file protection. The moment the object is instantiated, a lockfile is generated (which is destroyed after leaving the context). Attempts to access the file will be postponed as long as a lockfile exists. Furthermore, while in the context, file operations are done on a temporary file, that is only moved back when leaving the context. + +The reason to lock read access as well is that we might work with immutable files. The following scenario might happen: a file is read by process 1, some calculations are done by process 1, the file is read by process 2, and the result of the calculations is written by process 1. Now process 2 is working on an outdated version of the file. Hence the full process should be locked in one go: reading, manipulating/calculating, and writing. + +Several systems are in place to (almost) completely rule out concurrency and race conditions, to avoid file corruption. In the rare case where file corruption occurs, the original file is restored and the updated file stored under a different name. + +The tool works particularly well on EOS using the FsPath mechanics, however, on AFS it cannot be used reliably as different node servers can be out-of-sync with each other for a few seconds up to minutes. + +### Dev Tools for Xsuite +These are tools used for the maintenance and deployment of python packages. They are not in the test suite, and only accessible via `xaux.dev_tools`. The low-level functionality is a set of wrappers around `gh` (GitHub CLI), `git`, and `poetry`, while the higher-level functions are `make_release`, `make_release_branch`, `rename_release_branch` which are tailored to Xsuite and go through the same sequence of steps (verifying the release version number, making a PR to main, accepting it, publishing to PyPi, and making draft release notes on GitHub), while asserting the local workspace is clean and asking confirmation at each step. These are currently used as the default tools to maintain and deploy `xaux`, `xboinc`, `xcoll`, and `xdyna`. + +Finally, there are also some tools that wrap around `pip` and the `PyPi` API to get available package versions and make temporary installations of a specific package version from within python. diff --git a/xaux/dev_tools/__init__.py b/xaux/dev_tools/__init__.py index 767def8..bcd07db 100644 --- a/xaux/dev_tools/__init__.py +++ b/xaux/dev_tools/__init__.py @@ -6,4 +6,9 @@ from .release_tools import make_release, make_release_branch, rename_release_branch from .package_manager import import_package_version, install_package_version, get_package_versions, \ get_latest_package_version, get_package_dependencies, \ - get_package_version_dependencies \ No newline at end of file + get_package_version_dependencies +from .gh import assert_git_repo, assert_git_repo_name, assert_gh_installed, assert_poetry_installed, \ + git_assert_working_tree_clean, git_current_branch, git_rename_current_branch, \ + git_switch, git_add, git_commit, git_pull, git_push, git_make_tag, gh_pr_create, \ + gh_pr_list, gh_pr_merge, gh_release_create, poetry_bump_version, poetry_get_version, \ + poetry_get_expected_version, poetry_publish, GitError, GhError, PoetryError diff --git a/xaux/tools/class_property.py b/xaux/tools/class_property.py index c122387..485e30c 100644 --- a/xaux/tools/class_property.py +++ b/xaux/tools/class_property.py @@ -20,7 +20,15 @@ class ClassProperty: 'classproperty' will be attached to it, providing an attribute-like interface to the ClassProperty attributes of a class for introspection. Use like ?MyClass.classproperty.my_class_property to get the introspect. - - _classproperty_dependencies + - An important caveat is that regular class attributes do not always behave + as expected when inherited, which might be an issue when a ClassProperty + uses such a regular class attribute (for instance as the private attribute + it is encapsulating). Indeed, when the parent has a class attribute '_prop' + it will not be copied unto the child, and any ClassProperty.setter applied + on the child will inevitably update the parent's attributes as well. To + handle this, one can define a dict '_classproperty_dependencies' in the + class to declare all dependent regular class attributes and their initial + values. The 'ClassPropertyMeta' then copies these attributes to the child. Example usage: diff --git a/xaux/tools/singleton.py b/xaux/tools/singleton.py index 99e3cc6..a0ebc04 100644 --- a/xaux/tools/singleton.py +++ b/xaux/tools/singleton.py @@ -44,8 +44,8 @@ class is instantiated. - Each re-initialisation of the singleton will keep the class attributes, and for this reason, the __init__ method should not have any required arguments. This is asserted at the class creation time. - - By default, the singleton will not allow setting private attributes in the constructor, - but this can be overridden by setting 'allow_underscore_vars_in_init=True'. + - By default, the singleton allows setting private attributes in the constructor, + but this can be overridden by setting 'allow_underscore_vars_in_init=False'. - This decorator is fully compatible with inheritance, and each child of a singleton class will be its own singleton. - The singleton can be reset by calling the 'delete()' method of the class, which will From 701c5f0ce980fc09842f1cfe23c2479dff02bbe2 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Fri, 24 Jan 2025 04:51:15 +0100 Subject: [PATCH 26/26] Updated version number to v0.3.0. --- pyproject.toml | 2 +- tests/test_version.py | 2 +- xaux/general.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5a84e07..f62769f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xaux" -version = "0.3.0rc0" +version = "0.3.0" description = "Support tools for Xsuite packages" authors = ["Frederik F. Van der Veken ", "Thomas Pugnat ", diff --git a/tests/test_version.py b/tests/test_version.py index afad732..5b5c079 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -6,5 +6,5 @@ from xaux import __version__ def test_version(): - assert __version__ == '0.3.0rc0' + assert __version__ == '0.3.0' diff --git a/xaux/general.py b/xaux/general.py index 0167784..9452797 100644 --- a/xaux/general.py +++ b/xaux/general.py @@ -10,5 +10,5 @@ # =================== # Do not change # =================== -__version__ = '0.3.0rc0' +__version__ = '0.3.0' # ===================