feat: added property callbacks, added warnings

This commit is contained in:
Mose Müller 2023-08-02 12:06:19 +02:00
parent 8e7568b57f
commit 206a831473
7 changed files with 549 additions and 41 deletions

View File

@ -3,11 +3,16 @@ import inspect
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import Future from concurrent.futures import Future
from itertools import chain
from typing import Any from typing import Any
import rpyc import rpyc
from loguru import logger from loguru import logger
from pyDataInterface.utils import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
from .data_service_list import DataServiceList from .data_service_list import DataServiceList
@ -39,18 +44,127 @@ class DataService(rpyc.Service):
self._start_async_loop_in_thread() self._start_async_loop_in_thread()
self._start_autostart_tasks() self._start_autostart_tasks()
self._register_callbacks(self, f"{self.__class__.__name__}") self._register_DataService_callbacks(self, f"{self.__class__.__name__}")
self._turn_lists_into_notify_lists(self, f"{self.__class__.__name__}") self._register_list_change_callbacks(self, f"{self.__class__.__name__}")
self._do_something_with_properties() self._register_property_callbacks(self, f"{self.__class__.__name__}")
self._check_instance_classes()
self._initialised = True self._initialised = True
def _do_something_with_properties(self) -> None: def _check_instance_classes(self) -> None:
for attr_name in dir(self.__class__): for attr_name, attr_value in self.__get_class_and_instance_attributes().items():
attr_value = getattr(self.__class__, attr_name) # every class defined by the user should inherit from DataService
if isinstance(attr_value, property): # If attribute is a property if not attr_name.startswith("_DataService__"):
logger.debug(attr_value.fget.__code__.co_names) warn_if_instance_class_does_not_inherit_from_DataService(attr_value)
def _turn_lists_into_notify_lists( def __register_recursive_parameter_callback(
self,
obj: "DataService | DataServiceList",
callback: Callable[[str | int, Any], None],
) -> None:
"""
Register callback to the DataService instance and all its nested instances.
This method recursively traverses all attributes of the DataService `obj` and
adds the callback to each instance's `_callbacks` set when an attribute is a
DataService instance. This ensures any modification of attributes within
nested instances will trigger the provided callback.
"""
if isinstance(obj, DataServiceList):
# emits callback when item in list gets reassigned
obj.add_callback(callback=callback)
obj_list: DataServiceList | list[DataService] = obj
else:
obj_list = [obj]
# this enables notifications when a class instance was changed (-> item is
# changed, not reassigned)
for item in obj_list:
if isinstance(item, DataService):
item._callbacks.add(callback)
for attr_name in set(dir(item)) - set(dir(object)) - {"_root"}:
attr_value = getattr(item, attr_name)
if isinstance(attr_value, (DataService, DataServiceList)):
self.__register_recursive_parameter_callback(
attr_value, callback
)
def _register_property_callbacks(
self,
obj: "DataService",
parent_path: str,
) -> None:
"""
Register callbacks to emit notifications when attributes used in a property
getter are changed.
This method iterates over all attributes of the class. For each attribute that
is a property, it gets the names of the attributes used inside the property's
getter method. It then creates a callback for each of these dependent
attributes.
If the dependent attribute is a DataServiceList, the callback is added to the
list. So, if any element in the list is changed, the callback will be triggered
and a notification will be emitted.
If the dependent attribute is an instance of DataService, the callback is
registered to all nested DataService instances of this attribute using
`_register_recursive_callback`.
For all other types of attributes, the callback is simply added to the
`_callbacks` set of the instance.
"""
attrs = obj.__get_class_and_instance_attributes()
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, DataService):
self._register_property_callbacks(
attr_value, parent_path=f"{parent_path}.{attr_name}"
)
elif isinstance(attr_value, DataServiceList):
for i, item in enumerate(attr_value):
if isinstance(item, DataService):
self._register_property_callbacks(
item, parent_path=f"{parent_path}.{attr_name}[{i}]"
)
if isinstance(attr_value, property):
dependencies = attr_value.fget.__code__.co_names # type: ignore
for dependency in dependencies:
# use `obj` instead of `type(obj)` to get DataServiceList
# instead of list
dependency_value = getattr(obj, dependency)
if isinstance(dependency_value, (DataServiceList, DataService)):
callback = (
lambda name, value, dependent_attr=attr_name: obj._emit_notification(
parent_path=parent_path,
name=dependent_attr,
value=getattr(obj, dependent_attr),
)
if self == obj._root
else None
)
self.__register_recursive_parameter_callback(
dependency_value,
callback=callback,
)
else:
callback = (
lambda name, value, dependent_attr=attr_name, dep=dependency: obj._emit_notification(
parent_path=parent_path,
name=dependent_attr,
value=getattr(obj, dependent_attr),
)
if name == dep and self == obj._root
else None
)
# Add to _callbacks
obj._callbacks.add(callback)
def _register_list_change_callbacks(
self, obj: "DataService", parent_path: str self, obj: "DataService", parent_path: str
) -> None: ) -> None:
""" """
@ -82,12 +196,12 @@ class DataService(rpyc.Service):
""" """
# Convert all list attributes (both class and instance) to DataServiceList # Convert all list attributes (both class and instance) to DataServiceList
for attr_name in set(dir(obj)) - set(dir(object)) - {"_root"}: attrs = obj.__get_class_and_instance_attributes()
attr_value = getattr(obj, attr_name)
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, DataService): if isinstance(attr_value, DataService):
new_path = f"{parent_path}.{attr_name}" new_path = f"{parent_path}.{attr_name}"
self._turn_lists_into_notify_lists(attr_value, new_path) self._register_list_change_callbacks(attr_value, new_path)
elif isinstance(attr_value, list): elif isinstance(attr_value, list):
# Create callback for current attr_name # Create callback for current attr_name
# Default arguments solve the late binding problem by capturing the # Default arguments solve the late binding problem by capturing the
@ -107,7 +221,7 @@ class DataService(rpyc.Service):
if isinstance(attr_value, DataServiceList): if isinstance(attr_value, DataServiceList):
attr_value.add_callback(callback) attr_value.add_callback(callback)
continue continue
elif id(attr_value) in self._list_mapping: if id(attr_value) in self._list_mapping:
notifying_list = self._list_mapping[id(attr_value)] notifying_list = self._list_mapping[id(attr_value)]
notifying_list.add_callback(callback) notifying_list.add_callback(callback)
else: else:
@ -120,7 +234,7 @@ class DataService(rpyc.Service):
for i, item in enumerate(attr_value): for i, item in enumerate(attr_value):
if isinstance(item, DataService): if isinstance(item, DataService):
new_path = f"{parent_path}.{attr_name}[{i}]" new_path = f"{parent_path}.{attr_name}[{i}]"
self._turn_lists_into_notify_lists(item, new_path) self._register_list_change_callbacks(item, new_path)
def _start_autostart_tasks(self) -> None: def _start_autostart_tasks(self) -> None:
if self._autostart_tasks is not None: if self._autostart_tasks is not None:
@ -166,7 +280,9 @@ class DataService(rpyc.Service):
setattr(self, f"start_{name}", start_task) setattr(self, f"start_{name}", start_task)
setattr(self, f"stop_{name}", stop_task) setattr(self, f"stop_{name}", stop_task)
def _register_callbacks(self, obj: "DataService", parent_path: str) -> None: def _register_DataService_callbacks(
self, obj: "DataService", parent_path: str
) -> None:
""" """
This function is a key part of the observer pattern implemented by the This function is a key part of the observer pattern implemented by the
DataService class. DataService class.
@ -200,16 +316,20 @@ class DataService(rpyc.Service):
lambda name, value: obj._emit_notification( lambda name, value: obj._emit_notification(
parent_path=parent_path, name=name, value=value parent_path=parent_path, name=name, value=value
) )
if self == self._root if self == obj._root
and not name.startswith("_") # we are only interested in public attributes
and not isinstance(
getattr(type(obj), name, None), property
) # exlude proerty notifications -> those are handled in separate callbacks
else None else None
) )
obj._callbacks.add(callback) obj._callbacks.add(callback)
# Recursively register callbacks for all nested attributes of the object # Recursively register callbacks for all nested attributes of the object
attribute_set = set(dir(obj)) - set(dir(object)) - {"_root"} attrs = obj.__get_class_and_instance_attributes()
for nested_attr_name in attribute_set:
nested_attr = getattr(obj, nested_attr_name) for nested_attr_name, nested_attr in attrs.items():
if isinstance(nested_attr, list): if isinstance(nested_attr, list):
self._register_list_callbacks( self._register_list_callbacks(
nested_attr, parent_path, nested_attr_name nested_attr, parent_path, nested_attr_name
@ -238,7 +358,7 @@ class DataService(rpyc.Service):
nested_attr._root = self._root nested_attr._root = self._root
new_path = f"{parent_path}.{attr_name}" new_path = f"{parent_path}.{attr_name}"
self._register_callbacks(nested_attr, new_path) self._register_DataService_callbacks(nested_attr, new_path)
def _start_loop(self) -> None: def _start_loop(self) -> None:
asyncio.set_event_loop(self.__loop) asyncio.set_event_loop(self.__loop)
@ -256,8 +376,12 @@ class DataService(rpyc.Service):
if self.__dict__.get("_initialised") and not __name == "_initialised": if self.__dict__.get("_initialised") and not __name == "_initialised":
for callback in self._callbacks: for callback in self._callbacks:
callback(__name, __value) callback(__name, __value)
# TODO: add emits for properties -> can use co_names, which is a tuple elif __name.startswith(f"_{self.__class__.__name__}__"):
# containing the names used by the bytecode logger.warning(
f"Warning: You should not set private but rather protected attributes! "
f"Use {__name.replace(f'_{self.__class__.__name__}__', '_')} instead "
f"of {__name.replace(f'_{self.__class__.__name__}__', '__')}."
)
def _emit_notification(self, parent_path: str, name: str, value: Any) -> None: def _emit_notification(self, parent_path: str, name: str, value: Any) -> None:
logger.debug(f"{parent_path}.{name} changed to {value}!") logger.debug(f"{parent_path}.{name} changed to {value}!")
@ -269,7 +393,7 @@ class DataService(rpyc.Service):
# allow all other attributes # allow all other attributes
return getattr(self, name) return getattr(self, name)
def _rpyc_setattr(self, name: str, value: Any): def _rpyc_setattr(self, name: str, value: Any) -> None:
if name.startswith("_"): if name.startswith("_"):
# disallow special and private attributes # disallow special and private attributes
raise AttributeError("cannot access private/special names") raise AttributeError("cannot access private/special names")
@ -282,6 +406,20 @@ class DataService(rpyc.Service):
# allow all other attributes # allow all other attributes
setattr(self, name, value) setattr(self, name, value)
def __get_class_and_instance_attributes(self) -> dict[str, Any]:
"""Dictionary containing all attributes (both instance and class level) of a
given object.
If an attribute exists at both the instance and class level,the value from the
instance attribute takes precedence.
The _root object is removed as this will lead to endless recursion in the for
loops.
"""
attrs = dict(chain(type(self).__dict__.items(), self.__dict__.items()))
attrs.pop("_root")
return attrs
def serialize(self, prefix: str = "") -> dict[str, dict[str, Any]]: def serialize(self, prefix: str = "") -> dict[str, dict[str, Any]]:
""" """
Serializes the instance into a dictionary, preserving the structure of the Serializes the instance into a dictionary, preserving the structure of the
@ -314,18 +452,18 @@ class DataService(rpyc.Service):
result: dict[str, dict[str, Any]] = {} result: dict[str, dict[str, Any]] = {}
# Get the dictionary of the base class # Get the dictionary of the base class
base_dict = set(super().__class__.__dict__) base_set = set(type(super()).__dict__)
# Get the dictionary of the derived class # Get the dictionary of the derived class
derived_dict = set(self.__class__.__dict__) derived_set = set(type(self).__dict__)
# Get the difference between the two dictionaries # Get the difference between the two dictionaries
derived_only_dict = derived_dict - base_dict derived_only_set = derived_set - base_set
instance_dict = set(self.__dict__) instance_dict = set(self.__dict__)
# Merge the class and instance dictionaries # Merge the class and instance dictionaries
merged_dict = derived_only_dict | instance_dict merged_set = derived_only_set | instance_dict
# Iterate over attributes, properties, class attributes, and methods # Iterate over attributes, properties, class attributes, and methods
for key in merged_dict: for key in merged_set:
if key.startswith("_"): if key.startswith("_"):
continue # Skip attributes that start with underscore continue # Skip attributes that start with underscore

View File

@ -1,6 +1,10 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from pyDataInterface.utils import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
class DataServiceList(list): class DataServiceList(list):
""" """
@ -36,6 +40,9 @@ class DataServiceList(list):
if isinstance(callback, list): if isinstance(callback, list):
self.callbacks = callback self.callbacks = callback
for item in args[0]:
warn_if_instance_class_does_not_inherit_from_DataService(item)
# prevent gc to delete the passed list by keeping a reference # prevent gc to delete the passed list by keeping a reference
self._original_list = args[0] self._original_list = args[0]

View File

@ -0,0 +1,3 @@
from .warnings import warn_if_instance_class_does_not_inherit_from_DataService
__all__ = ["warn_if_instance_class_does_not_inherit_from_DataService"]

View File

@ -0,0 +1,14 @@
from loguru import logger
def warn_if_instance_class_does_not_inherit_from_DataService(__value: object) -> None:
base_class_name = __value.__class__.__base__.__name__
module_name = __value.__class__.__module__
if module_name not in ["builtins", "__builtin__"] and base_class_name not in [
"DataService",
"list",
]:
logger.warning(
f"Warning: Class {type(__value).__name__} does not inherit from DataService."
)

View File

@ -1,8 +1,20 @@
from collections.abc import Generator
from typing import Any from typing import Any
import pytest
from loguru import logger
from pytest import LogCaptureFixture
from pyDataInterface import DataService from pyDataInterface import DataService
@pytest.fixture
def caplog(caplog: LogCaptureFixture) -> Generator[LogCaptureFixture, Any, None]:
handler_id = logger.add(caplog.handler, format="{message}")
yield caplog
logger.remove(handler_id)
def emit(self: Any, parent_path: str, name: str, value: Any) -> None: def emit(self: Any, parent_path: str, name: str, value: Any) -> None:
if isinstance(value, DataService): if isinstance(value, DataService):
value = value.serialize() value = value.serialize()

View File

@ -5,30 +5,330 @@ from pyDataInterface import DataService
def test_properties(capsys: CaptureFixture) -> None: def test_properties(capsys: CaptureFixture) -> None:
class ServiceClass(DataService): class ServiceClass(DataService):
_power = True _voltage = 10.0
_current = 1.0
@property @property
def power(self) -> bool: def power(self) -> float:
return self._power return self._voltage * self.current
@power.setter
def power(self, value: bool) -> None:
self._power = value
@property @property
def power_two(self) -> bool: def voltage(self) -> float:
return self._power return self._voltage
@voltage.setter
def voltage(self, value: float) -> None:
self._voltage = value
@property
def current(self) -> float:
return self._current
@current.setter
def current(self, value: float) -> None:
self._current = value
test_service = ServiceClass() test_service = ServiceClass()
test_service.power = False test_service.voltage = 1
captured = capsys.readouterr() captured = capsys.readouterr()
expected_output = sorted( expected_output = sorted(
[ [
"ServiceClass.power = False", "ServiceClass.power = 1.0",
"ServiceClass.power_two = False", "ServiceClass.voltage = 1",
"ServiceClass._power = False",
] ]
) )
actual_output = sorted(captured.out.strip().split("\n")) actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output assert actual_output == expected_output
test_service.current = 12.0
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.power = 12.0",
"ServiceClass.current = 12.0",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
def test_nested_properties(capsys: CaptureFixture) -> None:
class SubSubClass(DataService):
name = "Hello"
class SubClass(DataService):
name = "Hello"
class_attr = SubSubClass()
class ServiceClass(DataService):
class_attr = SubClass()
name = "World"
@property
def subsub_name(self) -> str:
return f"{self.class_attr.class_attr.name} {self.name}"
@property
def sub_name(self) -> str:
return f"{self.class_attr.name} {self.name}"
test_service = ServiceClass()
test_service.name = "Peepz"
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.name = Peepz",
"ServiceClass.sub_name = Hello Peepz",
"ServiceClass.subsub_name = Hello Peepz",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
test_service.class_attr.name = "Hi"
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.sub_name = Hi Peepz",
"ServiceClass.subsub_name = Hello Peepz", # registers subclass changes
"ServiceClass.class_attr.name = Hi",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
test_service.class_attr.class_attr.name = "Ciao"
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.sub_name = Hi Peepz", # registers subclass changes
"ServiceClass.subsub_name = Ciao Peepz",
"ServiceClass.class_attr.class_attr.name = Ciao",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
def test_simple_list_properties(capsys: CaptureFixture) -> None:
class ServiceClass(DataService):
list = ["Hello", "Ciao"]
name = "World"
@property
def total_name(self) -> str:
return f"{self.list[0]} {self.name}"
test_service = ServiceClass()
test_service.name = "Peepz"
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.name = Peepz",
"ServiceClass.total_name = Hello Peepz",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
test_service.list[0] = "Hi"
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.total_name = Hi Peepz",
"ServiceClass.list[0] = Hi",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
def test_class_list_properties(capsys: CaptureFixture) -> None:
class SubClass(DataService):
name = "Hello"
class ServiceClass(DataService):
list = [SubClass()]
name = "World"
@property
def total_name(self) -> str:
return f"{self.list[0].name} {self.name}"
test_service = ServiceClass()
test_service.name = "Peepz"
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.name = Peepz",
"ServiceClass.total_name = Hello Peepz",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
test_service.list[0].name = "Hi"
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.total_name = Hi Peepz",
"ServiceClass.list[0].name = Hi",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
def test_subclass_properties(capsys: CaptureFixture) -> None:
class SubClass(DataService):
name = "Hello"
_voltage = 10.0
_current = 1.0
@property
def power(self) -> float:
return self._voltage * self.current
@property
def voltage(self) -> float:
return self._voltage
@voltage.setter
def voltage(self, value: float) -> None:
self._voltage = value
@property
def current(self) -> float:
return self._current
@current.setter
def current(self, value: float) -> None:
self._current = value
class ServiceClass(DataService):
class_attr = SubClass()
test_service = ServiceClass()
test_service.class_attr.voltage = 10.0
captured = capsys.readouterr()
expected_output = sorted(
[
"ServiceClass.class_attr.voltage = 10.0",
"ServiceClass.class_attr.power = 10.0",
]
)
actual_output = sorted(captured.out.strip().split("\n"))
assert actual_output == expected_output
def test_subclass_properties(capsys: CaptureFixture) -> None:
class SubClass(DataService):
name = "Hello"
_voltage = 10.0
_current = 1.0
@property
def power(self) -> float:
return self._voltage * self.current
@property
def voltage(self) -> float:
return self._voltage
@voltage.setter
def voltage(self, value: float) -> None:
self._voltage = value
@property
def current(self) -> float:
return self._current
@current.setter
def current(self, value: float) -> None:
self._current = value
class ServiceClass(DataService):
class_attr = SubClass()
@property
def voltage(self) -> float:
return self.class_attr.voltage
test_service = ServiceClass()
test_service.class_attr.voltage = 10.0
captured = capsys.readouterr()
expected_output = sorted(
{
"ServiceClass.class_attr.voltage = 10.0",
"ServiceClass.class_attr.power = 10.0",
"ServiceClass.voltage = 10.0",
}
)
# using a set here as "ServiceClass.voltage = 10.0" is emitted twice. Once for
# changing voltage, and once for changing power.
actual_output = sorted(set(captured.out.strip().split("\n")))
assert actual_output == expected_output
def test_subclass_properties_2(capsys: CaptureFixture) -> None:
class SubClass(DataService):
name = "Hello"
_voltage = 10.0
_current = 1.0
@property
def power(self) -> float:
return self._voltage * self.current
@property
def voltage(self) -> float:
return self._voltage
@voltage.setter
def voltage(self, value: float) -> None:
self._voltage = value
@property
def current(self) -> float:
return self._current
@current.setter
def current(self, value: float) -> None:
self._current = value
class ServiceClass(DataService):
class_attr = [SubClass() for i in range(2)]
@property
def voltage(self) -> float:
return self.class_attr[0].voltage
test_service = ServiceClass()
test_service.class_attr[1].current = 10.0
captured = capsys.readouterr()
expected_output = sorted(
{
"ServiceClass.class_attr[1].current = 10.0",
"ServiceClass.class_attr[1].power = 100.0",
"ServiceClass.voltage = 10.0",
}
)
# using a set here as "ServiceClass.voltage = 10.0" is emitted twice. Once for
# changing current, and once for changing power. Note that the voltage property is
# only dependent on class_attr[0] but still emits an update notification. This is
# because every time any item in the list `test_service.class_attr` is changed,
# a notification will be emitted.
actual_output = sorted(set(captured.out.strip().split("\n")))
assert actual_output == expected_output

34
tests/test_warnings.py Normal file
View File

@ -0,0 +1,34 @@
from pytest import LogCaptureFixture
from pyDataInterface import DataService
from . import caplog # noqa
def test_setattr_warnings(caplog: LogCaptureFixture) -> None: # noqa
# def test_setattr_warnings(capsys: CaptureFixture) -> None:
class SubClass:
name = "Hello"
class ServiceClass(DataService):
def __init__(self) -> None:
self.attr_1 = SubClass()
super().__init__()
ServiceClass()
assert "Warning: Class SubClass does not inherit from DataService." in caplog.text
def test_private_attribute_warning(caplog: LogCaptureFixture) -> None: # noqa
class ServiceClass(DataService):
def __init__(self) -> None:
self.__something = ""
super().__init__()
ServiceClass()
assert (
" Warning: You should not set private but rather protected attributes! Use "
"_something instead of __something." in caplog.text
)