Skip to content

Issue 61 #65

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ This release will contain a lot of new features and improvements so that a versi
Not finalised:
- cookie auth & its specification in TD (cookie auth branch)

## [v0.2.10] - 2025-04-05

- bug fixes to support `class_member` properties to work with `fget`, `fset` and `fdel` methods. While using custom `fget`, `fset` and `fdel` methods for `class_member`s,
the class will be passed as the first argument.

## [v0.2.9] - 2025-03-25

- bug fix to execute action when payload is explicitly null in a HTTP request. Whether action takes a payload or not, there was an error which caused the execution to be rejected.
Expand Down
2 changes: 1 addition & 1 deletion hololinked/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.9"
__version__ = "0.2.10"
92 changes: 71 additions & 21 deletions hololinked/param/parameterized.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> No
self.default = self.validate_and_adapt(self.default)

def __get__(self, obj : typing.Union['Parameterized', typing.Any],
objtype : typing.Union['ParameterizedMetaclass', typing.Any]) -> typing.Any: # pylint: disable-msg=W0613
objtype : typing.Union['ParameterizedMetaclass', typing.Any]) -> typing.Any: # pylint: disable-msg=W0613
"""
Return the value for this Parameter.

Expand All @@ -367,8 +367,13 @@ def __get__(self, obj : typing.Union['Parameterized', typing.Any],
class's value (default).
"""
if self.class_member:
return objtype.__dict__.get(self._internal_name, self.default)
if obj is None:
if self.fget is not None:
# self.fdef.__get__(None, objtype) is the same as self.fdel
return self.fget(objtype)
return getattr(objtype, self._internal_name, self.default)
if obj is None:
# this is a precedence why __get__ should be called with None for class_member
# therefore class_member logic above is handled in that way
return self
if self.fget is not None:
return self.fget(obj)
Expand Down Expand Up @@ -404,23 +409,30 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin
raise_ValueError("Read-only parameter cannot be set/modified.", self)

value = self.validate_and_adapt(value)

obj = obj if not self.class_member else self.owner


if self.class_member and obj is not self.owner: # safety check
obj = self.owner

old = NotImplemented
if self.constant:
old = None
if (obj.__dict__.get(self._internal_name, NotImplemented) != NotImplemented) or self.default is not None:
if (getattr(obj, self._internal_name, NotImplemented) != NotImplemented) or self.default is not None:
# Dont even entertain any type of setting, even if its the same value
raise_ValueError("Constant parameter cannot be modified.", self)
else:
old = obj.__dict__.get(self._internal_name, self.default)
old = getattr(obj, self._internal_name, self.default)

# The following needs to be optimised, probably through lambda functions?
if self.fset is not None:
# for class_member, self.fset.__get__(None, obj) is same as self.fset
self.fset(obj, value)
else:
obj.__dict__[self._internal_name] = value
if self.class_member:
# For class properties, store the value in the class's __dict__ using setattr
# as mapping proxy does not allow setting values directly
setattr(obj, self._internal_name, value)
else:
obj.__dict__[self._internal_name] = value

self._post_value_set(obj, value)

Expand Down Expand Up @@ -453,7 +465,13 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin

def __delete__(self, obj : typing.Union['Parameterized', typing.Any]) -> None:
if self.fdel is not None:
return self.fdel(obj)
if self.class_member:
# For class properties, bind the deletor to the class,
# especially when this method is called as del instance.parameter_name
# which will make obj take the value of the instance.
return self.fdel(self.owner)
elif obj is not self.owner:
return self.fdel(obj)
raise NotImplementedError("Parameter deletion not implemented.")

def validate_and_adapt(self, value : typing.Any) -> typing.Any:
Expand Down Expand Up @@ -1444,6 +1462,7 @@ def trigger(self, *parameters : str) -> None:
changed for a Parameter of type Event, setting it to True so
that it is clear which Event parameter has been triggered.
"""
raise NotImplementedError(wrap_error_text("""Triggering of events is not supported due to incomplete logic."""))
trigger_params = [p for p in self_.self_or_cls.param
if hasattr(self_.self_or_cls.param[p], '_autotrigger_value')]
triggers = {p:self_.self_or_cls.param[p]._autotrigger_value
Expand Down Expand Up @@ -1787,19 +1806,50 @@ def __setattr__(mcs, attribute_name : str, value : typing.Any) -> None:
# class attribute of this class - if not, parameter is None.
if attribute_name != '_param_container' and attribute_name != '__%s_params__' % mcs.__name__:
parameter = mcs.parameters.descriptors.get(attribute_name, None)
# checking isinstance(value, Parameter) will not work for ClassSelector
# and besides value is anyway validated. On the downside, this does not allow
# altering of parameter instances if class already of the parameter with attribute_name
if parameter: # and not isinstance(value, Parameter):
# if owning_class != mcs:
# parameter = copy.copy(parameter)
# parameter.owner = mcs
# type.__setattr__(mcs, attribute_name, parameter)
mcs.__dict__[attribute_name].__set__(mcs, value)
parameter.__set__(mcs, value)
return
# set with None should not supported as with mcs it supports
# class attributes which can be validated
type.__setattr__(mcs, attribute_name, value)
return type.__setattr__(mcs, attribute_name, value)

def __getattr__(mcs, attribute_name : str) -> typing.Any:
"""
Implements 'self.attribute_name' in a way that also supports Parameters.

If there is a Parameter descriptor named attribute_name, it will be
retrieved using the descriptor protocol.
"""
if attribute_name != '_param_container' and attribute_name != '__%s_params__' % mcs.__name__:
parameter = mcs.parameters.descriptors.get(attribute_name, None)
if parameter and parameter.class_member:
return parameter.__get__(None, mcs)
return type.__getattr__(mcs, attribute_name)

def __delattr__(mcs, attribute_name : str) -> None:
"""
Implements 'del self.attribute_name' in a way that also supports Parameters.

If there is a Parameter descriptor named attribute_name, it will be deleted
from the class. This is different from setting the parameter value to None,
as it completely removes the parameter from the class.
"""
if attribute_name != '_param_container' and attribute_name != '__%s_params__' % mcs.__name__:
parameter = mcs.parameters.descriptors.get(attribute_name, None)
if parameter:
# Delete the parameter from the descriptors dictionary
try:
parameter.__delete__(mcs)
return
except NotImplementedError: # raised by __delete__ if fset is not defined
del mcs.parameters.descriptors[attribute_name]
# Delete the parameter from the instance parameters dictionary
try:
delattr(mcs, '__%s_params__' % mcs.__name__)
except AttributeError:
pass
# After deleting the parameter from our own reference,
# we also delete it from the class, so dont return but pass the call
# to type.__delattr__
return type.__delattr__(mcs, attribute_name)



Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

setuptools.setup(
name="hololinked",
version="0.2.9",
version="0.2.10",
author="Vignesh Vaidyanathan",
author_email="vignesh.vaidyanathan@hololinked.dev",
description="A ZMQ-based Object Oriented RPC tool-kit for instrument control/data acquisition or controlling generic python objects.",
Expand Down
167 changes: 167 additions & 0 deletions tests/test_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,173 @@ def test_6_pydantic_model_property(self):



class TestClassPropertyThing(Thing):
# Simple class property with default value
simple_class_prop = Number(class_member=True, default=42)

# Class property with custom getter/setter
managed_class_prop = Number(class_member=True)

@managed_class_prop.getter
def get_managed_class_prop(cls):
return getattr(cls, '_managed_value', 0)

@managed_class_prop.setter
def set_managed_class_prop(cls, value):
if value < 0:
raise ValueError("Value must be non-negative")
cls._managed_value = value

# Read-only class property
readonly_class_prop = String(class_member=True, readonly=True)

@readonly_class_prop.getter
def get_readonly_class_prop(cls):
return "read-only-value"

# Deletable class property
deletable_class_prop = Number(class_member=True, default=100)

@deletable_class_prop.getter
def get_deletable_class_prop(cls):
return getattr(cls, '_deletable_value', 100)

@deletable_class_prop.setter
def set_deletable_class_prop(cls, value):
cls._deletable_value = value

@deletable_class_prop.deleter
def del_deletable_class_prop(cls):
if hasattr(cls, '_deletable_value'):
del cls._deletable_value

not_a_class_prop = Number(class_member=False, default=43)

@not_a_class_prop.getter
def get_not_a_class_prop(self):
return getattr(self, '_not_a_class_value', 43)

@not_a_class_prop.setter
def set_not_a_class_prop(self, value):
self._not_a_class_value = value

@not_a_class_prop.deleter
def del_not_a_class_prop(self):
if hasattr(self, '_not_a_class_value'):
del self._not_a_class_value


class TestClassProperty(TestCase):

def test_1_simple_class_property(self):
"""Test basic class property functionality"""
# Test class-level access
self.assertEqual(TestClassPropertyThing.simple_class_prop, 42)
TestClassPropertyThing.simple_class_prop = 100
self.assertEqual(TestClassPropertyThing.simple_class_prop, 100)

# Test that instance-level access reflects class value
instance1 = TestClassPropertyThing(instance_name='test1', log_level=logging.WARN)
instance2 = TestClassPropertyThing(instance_name='test2', log_level=logging.WARN)
self.assertEqual(instance1.simple_class_prop, 100)
self.assertEqual(instance2.simple_class_prop, 100)

# Test that instance-level changes affect class value
instance1.simple_class_prop = 200
self.assertEqual(TestClassPropertyThing.simple_class_prop, 200)
self.assertEqual(instance2.simple_class_prop, 200)

def test_2_managed_class_property(self):
"""Test class property with custom getter/setter"""
# Test initial value
self.assertEqual(TestClassPropertyThing.managed_class_prop, 0)

# Test valid value assignment
TestClassPropertyThing.managed_class_prop= 50
self.assertEqual(TestClassPropertyThing.managed_class_prop, 50)

# Test validation in setter
with self.assertRaises(ValueError):
TestClassPropertyThing.managed_class_prop = -10

# Verify value wasn't changed after failed assignment
self.assertEqual(TestClassPropertyThing.managed_class_prop, 50)

# Test instance-level validation
instance = TestClassPropertyThing(instance_name='test3', log_level=logging.WARN)
with self.assertRaises(ValueError):
instance.managed_class_prop = -20

# Test that instance-level access reflects class value
self.assertEqual(instance.managed_class_prop, 50)

# Test that instance-level changes affects class value
instance.managed_class_prop = 100
self.assertEqual(TestClassPropertyThing.managed_class_prop, 100)
self.assertEqual(instance.managed_class_prop, 100)

def test_3_readonly_class_property(self):
"""Test read-only class property behavior"""
# Test reading the value
self.assertEqual(TestClassPropertyThing.readonly_class_prop, "read-only-value")

# Test that setting raises an error at class level
with self.assertRaises(ValueError):
TestClassPropertyThing.readonly_class_prop = "new-value"

# Test that setting raises an error at instance level
instance = TestClassPropertyThing(instance_name='test4', log_level=logging.WARN)
with self.assertRaises(ValueError):
instance.readonly_class_prop = "new-value"

# Verify value remains unchanged
self.assertEqual(TestClassPropertyThing.readonly_class_prop, "read-only-value")
self.assertEqual(instance.readonly_class_prop, "read-only-value")

def test_4_deletable_class_property(self):
"""Test class property deletion"""
# Test initial value
self.assertEqual(TestClassPropertyThing.deletable_class_prop, 100)

# Test setting new value
TestClassPropertyThing.deletable_class_prop = 150
self.assertEqual(TestClassPropertyThing.deletable_class_prop, 150)

# Test deletion
instance = TestClassPropertyThing(instance_name='test5', log_level=logging.WARN)
del TestClassPropertyThing.deletable_class_prop
self.assertEqual(TestClassPropertyThing.deletable_class_prop, 100) # Should return to default
self.assertEqual(instance.deletable_class_prop, 100)

# Test instance-level deletion
instance.deletable_class_prop = 200
self.assertEqual(TestClassPropertyThing.deletable_class_prop, 200)
del instance.deletable_class_prop
self.assertEqual(TestClassPropertyThing.deletable_class_prop, 100) # Should return to default

def test_5_descriptor_access(self):
"""Test descriptor access for class properties"""
# Test direct access through descriptor
instance = TestClassPropertyThing(instance_name='test6', log_level=logging.WARN)
self.assertIsInstance(TestClassPropertyThing.not_a_class_prop, Number)
self.assertEqual(instance.not_a_class_prop, 43)
instance.not_a_class_prop = 50
self.assertEqual(instance.not_a_class_prop, 50)

del instance.not_a_class_prop
# deleter deletes only an internal instance variable
self.assertTrue(hasattr(TestClassPropertyThing, 'not_a_class_prop'))
self.assertEqual(instance.not_a_class_prop, 43)

del TestClassPropertyThing.not_a_class_prop
# descriptor itself is deleted
self.assertFalse(hasattr(TestClassPropertyThing, 'not_a_class_prop'))
self.assertFalse(hasattr(instance, 'not_a_class_prop'))
with self.assertRaises(AttributeError):
instance.not_a_class_prop



if __name__ == '__main__':
unittest.main(testRunner=TestRunner())