# -*- coding: utf-8 -*-
"""
Descriptor objects that can be used to simplify common tasks related to object attributes.
Copyright (c) 2023, the qudi developers. See the AUTHORS.md file at the top-level directory of this
distribution and on <https://github.com/Ulm-IQO/qudi-core/>
This file is part of qudi.
Qudi is free software: you can redistribute it and/or modify it under the terms of
the GNU Lesser General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.
Qudi is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along with qudi.
If not, see <https://www.gnu.org/licenses/>.
"""
__all__ = ['BaseAttribute', 'DefaultAttribute', 'ReadOnlyAttribute', 'TypedAttribute',
'CheckedAttribute', 'DefaultMixin', 'ReadOnlyMixin', 'TypedMixin', 'ValidateMixin']
from typing import Any, Optional, Iterable, Type, Callable, Union
from inspect import isclass, isfunction
[docs]
class DefaultMixin:
"""Mixin for BaseAttribute introducing optional default value behaviour in __get__.
If no default value is specified, fall back to raising AttributeError.
"""
_no_default = object() # unique placeholder
[docs]
def __init__(self, default: Optional[Any] = _no_default, **kwargs):
super().__init__(**kwargs)
self.default = default
def __get__(self, instance, owner):
try:
return super().__get__(instance, owner)
except AttributeError:
if self.default is self._no_default:
raise
return self.default
def __delete__(self, instance):
try:
super().__delete__(instance)
except AttributeError:
pass
[docs]
class ReadOnlyMixin:
"""Mixin for BaseAttribute introducing read-only access."""
def __delete__(self, instance):
raise AttributeError('Read-only attribute can not be deleted')
def __set__(self, instance, value):
raise AttributeError('Read-only attribute can not be overwritten')
def set_value(self, instance: object, value: Any) -> None:
super().__set__(instance, value)
[docs]
class TypedMixin:
"""Mixin for BaseAttribute introducing optional type checking via isinstance builtin."""
[docs]
def __init__(self, valid_types: Optional[Iterable[Type]] = None, **kwargs):
super().__init__(**kwargs)
self.valid_types = None if valid_types is None else tuple(valid_types)
if self.valid_types and not all(isclass(typ) for typ in self.valid_types):
raise TypeError('valid_types must be iterable of types (classes)')
def __set__(self, instance, value):
self.check_type(value)
super().__set__(instance, value)
def check_type(self, value: Any) -> None:
if self.valid_types and not isinstance(value, self.valid_types):
raise TypeError(
f'Value must be of type(s) [{", ".join(t.__name__ for t in self.valid_types)}]'
)
[docs]
class ValidateMixin:
"""Mixin for BaseAttribute introducing optional validation via registering static and/or
bound validator methods.
Bound methods are best registered via the "validator" decorator (cooperative with
staticmethod/classmethod decorator).
"""
[docs]
def __init__(self,
static_validators: Optional[Iterable[Callable[[Any], None]]] = None,
**kwargs):
super().__init__(**kwargs)
self.static_validators = list() if static_validators is None else list(static_validators)
self.bound_validators = list()
if not all(callable(val) for val in self.static_validators):
raise TypeError('static_validators must be iterable of callables')
def __set__(self, instance, value):
self.validate(value, instance)
super().__set__(instance, value)
def validator(self,
func: Union[staticmethod, classmethod, Callable[[Any], None]]
) -> Union[staticmethod, classmethod, Callable[[Any], None]]:
"""Decorator to register either a static or bound validator."""
# Use function reference directly if static
if isinstance(func, staticmethod):
self.static_validators.append(func.__func__)
return func
# In case of bound methods (class/instance) just use the attribute name string
if isinstance(func, classmethod):
func_obj = func.__func__
elif isfunction(func):
func_obj = func
if func_obj.__qualname__ == func_obj.__name__:
# Not a class member, thus probably static
self.static_validators.append(func)
return func
else:
raise TypeError('validator must either be function, staticmethod or classmethod object')
# Take care of name mangling for private members
if func_obj.__name__.startswith('__'):
cls_name = func_obj.__qualname__.rsplit('.', 1)[0]
self.bound_validators.append(f'_{cls_name}{func_obj.__name__}')
else:
self.bound_validators.append(func_obj.__name__)
return func
def validate(self, value: Any, instance: Optional[Any] = None) -> None:
try:
for func in self.static_validators:
func(value)
for func_name in self.bound_validators:
try:
func = getattr(instance, func_name)
except AttributeError:
raise AttributeError(
f'Registered bound validator "{func_name}" not found in {instance}'
) from None
func(value)
except Exception as err:
raise ValueError(f'Value "{value}" did not pass validation') from err
[docs]
class BaseAttribute:
"""Base descriptor class implementing trivial get/set/delete behaviour for an instance
attribute.
"""
[docs]
def __init__(self):
super().__init__()
self.attr_name = None
def __set_name__(self, owner, name):
self.attr_name = name
def __get__(self, instance, owner):
try:
return instance.__dict__[self.attr_name]
except KeyError:
raise AttributeError(self.attr_name) from None
except AttributeError:
return self
def __delete__(self, instance):
try:
del instance.__dict__[self.attr_name]
except KeyError:
raise AttributeError(self.attr_name) from None
def __set__(self, instance, value):
instance.__dict__[self.attr_name] = value
[docs]
class DefaultAttribute(DefaultMixin, BaseAttribute):
"""Attribute that can be given a default value which is used if not explicitly initialized by
the instance.
Example usage:
class Test:
variable_a = DefaultAttribute(42)
variable_b = DefaultAttribute()
def __init__(self):
self.variable_b = self.variable_a - 42
assert self.variable_a == 42
assert self.variable_b == 0
"""
[docs]
def __init__(self, default: Optional[Any] = DefaultMixin._no_default):
super().__init__(default=default)
[docs]
class ReadOnlyAttribute(ReadOnlyMixin, DefaultAttribute):
"""Extension of DefaultAttribute to be read-only. A non-default value can be set by calling
"set_value(instance, value)" on the descriptor instance.
Example usage:
class Test:
variable_a = ReadOnlyAttribute(42)
variable_b = ReadOnlyAttribute()
def __init__(self):
self.__class__.variable_b.set_value(self, self.variable_a - 42)
assert self.variable_a == 42
assert self.variable_b == 0
# The following would raise an AttributeError
# self.variable_b = 0
"""
pass
[docs]
class TypedAttribute(TypedMixin, DefaultAttribute):
"""Extension of DefaultAttribute including type checking via isinstance. A given default
value is not type-checked.
Example usage:
class Test:
variable_a = TypedAttribute([int, float])
variable_b = TypedAttribute([str], None)
def __init__(self):
assert self.variable_b is None
self.variable_a = 42
self.variable_b = 'hello world'
assert self.variable_a == 42
assert self.variable_b == 'hello world'
# The following would raise TypeError
# self.variable_a = self.variable_b = None
"""
[docs]
def __init__(self,
valid_types: Optional[Iterable[Type]] = None,
default: Optional[Any] = DefaultAttribute._no_default):
super().__init__(valid_types=valid_types, default=default)
[docs]
class CheckedAttribute(TypedMixin, ValidateMixin, DefaultAttribute):
"""Extension of DefaultAttribute including optional validation via static or bound validator
methods as well as optional type checking via "isinstance".
A given default value is not validated. Type checking is performed before validation.
Register bound validator methods via the CheckedAttribute.validator decorator. This decorator
can be combined with classmethod/staticmethod decorators in any order.
Example usage:
def my_static_validator(value):
if not (0 <= value <= 100):
raise ValueError('Value must be number between 0 and 100')
class Test:
variable_a = CheckedAttribute([my_static_validator], [int, float], 0)
variable_b = CheckedAttribute(valid_types=[str])
_valid_strings = ['A', 'B', 'C']
def __init__(self):
self.variable_a = 66.7
self.variable_b = 'B'
assert self.variable_a == 66.7
assert self.variable_b == 'B'
# The following would raise ValueError
# self.variable_a = 101
# self.variable_b = 'D'
@variable_b.validator
@classmethod
def _validate_variable_b(cls, value):
if value not in cls._valid_strings:
raise ValueError(f'Invalid string. Valid strings are: {cls._valid_strings}')
"""
[docs]
def __init__(self,
static_validators: Optional[Iterable[Callable[[Any], None]]] = None,
valid_types: Optional[Iterable[Type]] = None,
default: Optional[Any] = DefaultAttribute._no_default):
super().__init__(static_validators=static_validators,
valid_types=valid_types,
default=default)