From 1c4f73365f19ce4f94f5a1c41e3bf1e72719b3c8 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Tue, 19 May 2026 17:07:35 +0200 Subject: [PATCH 01/36] [changing-rpc-constants-on-proxy] Modifying `qmi.core.rpc` such that the class constants marked with `_rpc_constants = ["constant_a", "constant_b", ...]` can be changed by calling `class_rpc_proxy_inst.constant_a(new_value)`. The new value must be of same type and length as the original value to be able to change the value. This means that e.g. `None` cannot be used to set the RPC constant as 'optional', and the constant cannot have union of types either. Need to edit the documentation about this still. --- CHANGELOG.md | 3 + qmi/core/rpc.py | 547 ++++++++++++------ qmi/core/util.py | 64 ++ qmi/instruments/dummy/noisy_sine_generator.py | 27 +- tests/core/test_util.py | 56 ++ tests/instruments/dummy/__init__.py | 0 .../dummy/test_noisy_sine_generator.py | 188 ++++++ 7 files changed, 702 insertions(+), 183 deletions(-) create mode 100644 tests/core/test_util.py create mode 100644 tests/instruments/dummy/__init__.py create mode 100644 tests/instruments/dummy/test_noisy_sine_generator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c89dd414..72d30644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.54.0-beta.0] - Unreleased +### Added +- Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects. + ## [0.53.0] - 2026-05-11 ### Added diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index 5d91fc44..c30eb5d2 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -152,13 +152,15 @@ def square(self, x): QMI_UsageException, QMI_MessageDeliveryException, QMI_RpcTimeoutException, - QMI_UnknownRpcException) + QMI_UnknownRpcException +) from qmi.core.messaging import ( QMI_Message, QMI_RequestMessage, QMI_ReplyMessage, QMI_ErrorReplyMessage, - QMI_MessageHandler, QMI_MessageHandlerAddress) + QMI_MessageHandler, QMI_MessageHandlerAddress +) from qmi.core.pubsub import SignalDescription, QMI_Signal, QMI_RegisteredSignal, QMI_SignalSubscriber from qmi.core.thread import QMI_Thread -from qmi.core.util import is_valid_object_name +from qmi.core.util import check_value_structures_equal, is_valid_object_name # Global variable holding the logger for this module. @@ -172,7 +174,7 @@ class RpcConstantDescriptor(NamedTuple): """Description of an RPC constant. Attributes: - name: Name of the constant. + name: Name of the constant. value: Value of the constant. """ name: str @@ -183,9 +185,9 @@ class RpcMethodDescriptor(NamedTuple): """Description of an RPC method. Attributes: - name: Name of the RPC method. + name: Name of the RPC method. signature: String representation of the signature of the RPC method, - including type annotations. + including type annotations. docstring: Docstring of the RPC method. """ name: str @@ -197,7 +199,7 @@ class RpcSignalDescriptor(NamedTuple): """Description of a QMI signal. Attributes: - name: Name of the signal. + name: Name of the signal. arg_types: String representation of the list of argument types. """ name: str @@ -211,16 +213,15 @@ class RpcInterfaceDescriptor(NamedTuple): a delegate class. Attributes: - rpc_class_module: Name of the module in which the RPC object class was - defined. - rpc_class_name: Name of the RPC object class. + rpc_class_module: Name of the module in which the RPC object class was defined. + rpc_class_name: Name of the RPC object class. rpc_class_docstring: Docstring of the RPC object class. - constants: A list of constant descriptors for the RPC constants declared - by the RPC object class. - methods: A list of method descriptors for the RPC methods declared by - the RPC object class. - signals: A list of signal descriptors for the signals declared by the - RPC object class or a delegate class. + constants: A list of constant descriptors for the RPC constants declared + by the RPC object class. + methods: A list of method descriptors for the RPC methods declared by + the RPC object class. + signals: A list of signal descriptors for the signals declared by the + RPC object class or a delegate class. """ rpc_class_module: str rpc_class_name: str @@ -234,11 +235,10 @@ class RpcObjectDescriptor(NamedTuple): """Description of an RPC object instance. Attributes: - address: Unique address of the RPC object. - category: Free-form name of the category of objects this RPC object - belongs to. - interface: Description of the subset of the interface of the RPC object - that can be accessed via RPC, including signals. + address: Unique address of the RPC object. + category: Free-form name of the category of objects this RPC object belongs to. + interface: Description of the subset of the interface of the RPC object that can be accessed via RPC, + including signals. """ address: QMI_MessageHandlerAddress category: str | None @@ -283,17 +283,18 @@ class QMI_LockRpcRequestMessage(QMI_RequestMessage): See `QMI_LockRpcReplyMessage` for how to interpret the reply to a request. Attributes: - lock_token: The unique token to use for the lock. - lock_action: The action to be performed on the lock state. + lock_token: The unique token to use for the lock. + lock_action: The action to be performed on the lock state. """ __slots__ = ("lock_token", "lock_action") - def __init__(self, - source_address: QMI_MessageHandlerAddress, - destination_address: QMI_MessageHandlerAddress, - lock_token: QMI_LockTokenDescriptor | None, - lock_action: QMI_LockRpcAction - ) -> None: + def __init__( + self, + source_address: QMI_MessageHandlerAddress, + destination_address: QMI_MessageHandlerAddress, + lock_token: QMI_LockTokenDescriptor | None, + lock_action: QMI_LockRpcAction + ) -> None: super().__init__(source_address, destination_address) self.lock_token = lock_token self.lock_action = lock_action @@ -317,16 +318,65 @@ class QMI_LockRpcReplyMessage(QMI_ReplyMessage): """ __slots__ = ("lock_token",) - def __init__(self, - source_address: QMI_MessageHandlerAddress, - destination_address: QMI_MessageHandlerAddress, - request_id: str, - lock_token: QMI_LockTokenDescriptor | None - ) -> None: + def __init__( + self, + source_address: QMI_MessageHandlerAddress, + destination_address: QMI_MessageHandlerAddress, + request_id: str, + lock_token: QMI_LockTokenDescriptor | None + ) -> None: super().__init__(source_address, destination_address, request_id) self.lock_token = lock_token +class QMI_ConstantRpcRequestMessage(QMI_RequestMessage): + """Message sent by an RPC client to change a RPC constant value of a remote object. + + See `QMI_ConstantRpcReplyMessage` for how to interpret the reply to a request. + + Attributes: + constant_name: The constant name. + constant_value: The value or values of the constants. + lock_token: The unique token to use for the lock. + """ + __slots__ = ("constant_name", "constant_value", "lock_token") + + def __init__( + self, + source_address: QMI_MessageHandlerAddress, + destination_address: QMI_MessageHandlerAddress, + constant_name: str, + constant_value: Any, + lock_token: QMI_LockTokenDescriptor | None = None + ) -> None: + super().__init__(source_address, destination_address) + self.constant_name = constant_name + self.constant_value = constant_value + self.lock_token = lock_token + + +class QMI_ConstantRpcReplyMessage(QMI_ReplyMessage): + """Message sent back to an RPC client with the result of the action. + + Attributes: + state: Either `RESULT_IS_VALUE`, `RESULT_IS_EXCEPTION` or `OBJECT_IS_LOCKED`. + result: Return value from the method or exception raised by the method. + """ + __slots__ = ("state", "result") + + def __init__( + self, + source_address: QMI_MessageHandlerAddress, + destination_address: QMI_MessageHandlerAddress, + request_id: str, + state: QMI_RpcFutureState, + result: Any + ) -> None: + super().__init__(source_address, destination_address, request_id) + self.state = state + self.result = result + + class QMI_MethodRpcRequestMessage(QMI_RequestMessage): """Message sent by an RPC client to invoke a remote method. @@ -338,14 +388,15 @@ class QMI_MethodRpcRequestMessage(QMI_RequestMessage): """ __slots__ = ("method_name", "method_args", "method_kwargs", "lock_token") - def __init__(self, - source_address: QMI_MessageHandlerAddress, - destination_address: QMI_MessageHandlerAddress, - method_name: str, - method_args: tuple, - method_kwargs: dict, - lock_token: QMI_LockTokenDescriptor | None = None - ) -> None: + def __init__( + self, + source_address: QMI_MessageHandlerAddress, + destination_address: QMI_MessageHandlerAddress, + method_name: str, + method_args: tuple, + method_kwargs: dict, + lock_token: QMI_LockTokenDescriptor | None = None + ) -> None: super().__init__(source_address, destination_address) self.method_name = method_name self.method_args = method_args @@ -357,18 +408,19 @@ class QMI_MethodRpcReplyMessage(QMI_ReplyMessage): """Message sent back to an RPC client with the result of a remote method invocation. Attributes: - state: Either `RESULT_IS_VALUE`, `RESULT_IS_EXCEPTION` or `OBJECT_IS_LOCKED`. + state: Either `RESULT_IS_VALUE`, `RESULT_IS_EXCEPTION` or `OBJECT_IS_LOCKED`. result: Return value from the method or exception raised by the method. """ __slots__ = ("state", "result") - def __init__(self, - source_address: QMI_MessageHandlerAddress, - destination_address: QMI_MessageHandlerAddress, - request_id: str, - state: QMI_RpcFutureState, - result: Any - ) -> None: + def __init__( + self, + source_address: QMI_MessageHandlerAddress, + destination_address: QMI_MessageHandlerAddress, + request_id: str, + state: QMI_RpcFutureState, + result: Any + ) -> None: super().__init__(source_address, destination_address, request_id) self.state = state self.result = result @@ -389,11 +441,12 @@ class QMI_RpcFuture(QMI_MessageHandler): class without waiting for the real method call to end. """ - def __init__(self, - context: "qmi.core.context.QMI_Context", - rpc_object_address: QMI_MessageHandlerAddress, - lock_token: QMI_LockTokenDescriptor | None, - ) -> None: + def __init__( + self, + context: "qmi.core.context.QMI_Context", + rpc_object_address: QMI_MessageHandlerAddress, + lock_token: QMI_LockTokenDescriptor | None, + ) -> None: future_address = context.make_unique_address("$future_") super().__init__(future_address) @@ -406,16 +459,17 @@ def __init__(self, context.register_message_handler(self) - def send_method_rpc_request_message(self, - rpc_method_name: str, - rpc_method_args: tuple, - rpc_method_kwargs: dict - ) -> None: + def send_method_rpc_request_message( + self, + rpc_method_name: str, + rpc_method_args: tuple, + rpc_method_kwargs: dict + ) -> None: """Send a request message to the RPC object to invoke the specified method. Parameters: - rpc_method_name: Name of the method to call. - rpc_method_args: Tuple of positional arguments. + rpc_method_name: Name of the method to call. + rpc_method_args: Tuple of positional arguments. rpc_method_kwargs: Dictionary of keyword arguments. """ request = QMI_MethodRpcRequestMessage( @@ -429,6 +483,32 @@ def send_method_rpc_request_message(self, try: self._context.send_message(request) + + except QMI_MessageDeliveryException as exc: + self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, exc) + + def send_constant_rpc_request_message( + self, + rpc_constant_name: str, + rpc_constant_value: Any + ) -> None: + """Send a request message to the RPC object to modify the specified constant. + + Parameters: + rpc_constant_name: Name of the constant to modify. + rpc_constant_value: The new constant value. + """ + request = QMI_ConstantRpcRequestMessage( + self.address, + self.rpc_object_address, + rpc_constant_name, + rpc_constant_value, + self.lock_token + ) + + try: + self._context.send_message(request) + except QMI_MessageDeliveryException as exc: self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, exc) @@ -436,6 +516,7 @@ def send_lock_rpc_request_message(self, action: QMI_LockRpcAction) -> None: request = QMI_LockRpcRequestMessage(self.address, self.rpc_object_address, self.lock_token, action) try: self._context.send_message(request) + except QMI_MessageDeliveryException as exc: self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, exc) @@ -443,37 +524,49 @@ def handle_message(self, message: QMI_Message) -> None: """Called when a reply message is received.""" if isinstance(message, QMI_MethodRpcReplyMessage): - # Received result from RPC call. + # Received result from RPC method call. + self._set_result(message.state, message.result) + elif isinstance(message, QMI_ConstantRpcReplyMessage): + # Received result from RPC constant call. self._set_result(message.state, message.result) elif isinstance(message, QMI_LockRpcReplyMessage): # Response to lock request message. self._set_result(QMI_RpcFutureState.RESULT_IS_VALUE, message.lock_token) elif isinstance(message, QMI_ErrorReplyMessage): # Delivery of RPC request failed. - self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, - QMI_MessageDeliveryException(message.error_msg)) + self._set_result( + QMI_RpcFutureState.RESULT_IS_EXCEPTION, QMI_MessageDeliveryException(message.error_msg) + ) else: - _logger.error("Future for %s.%s received unexpected message type %r", - self.rpc_object_address.context_id, - self.rpc_object_address.object_id, - type(message)) + _logger.error( + "Future for %s.%s received unexpected message type %r", + self.rpc_object_address.context_id, + self.rpc_object_address.object_id, + type(message) + ) def _set_result(self, state: QMI_RpcFutureState, result: Any) -> None: """Store the result received from the RPC reply and wake up any task waiting for this result.""" - if state not in (QMI_RpcFutureState.RESULT_IS_EXCEPTION, - QMI_RpcFutureState.RESULT_IS_VALUE, - QMI_RpcFutureState.OBJECT_IS_LOCKED): - _logger.error("Future for %s.%s received unexpected state %r", - self.rpc_object_address.context_id, - self.rpc_object_address.object_id, - state) + if state not in ( + QMI_RpcFutureState.RESULT_IS_EXCEPTION, + QMI_RpcFutureState.RESULT_IS_VALUE, + QMI_RpcFutureState.OBJECT_IS_LOCKED + ): + _logger.error( + "Future for %s.%s received unexpected state %r", + self.rpc_object_address.context_id, + self.rpc_object_address.object_id, + state + ) return with self._cv: if self._state != QMI_RpcFutureState.NO_RESULT_YET: - _logger.error("Future for %s.%s received duplicate reply message", - self.rpc_object_address.context_id, - self.rpc_object_address.object_id) + _logger.error( + "Future for %s.%s received duplicate reply message", + self.rpc_object_address.context_id, + self.rpc_object_address.object_id + ) return self._state = state @@ -490,8 +583,9 @@ def wait(self, timeout: float | None = None) -> Any: The return value from the associated RPC method call. Raises: + QMI_RuntimeException: If the associated RPC method call raised an exception. + QMI_RuntimeException: If the RPC object was locked by another proxy. QMI_RpcTimeoutException: If the timeout expires before the RPC call completes. - Exception: If the associated RPC method call raised an exception. """ if timeout is not None: time_limit = time.monotonic() + timeout @@ -502,13 +596,13 @@ def wait(self, timeout: float | None = None) -> Any: # Check state of the future. if self._state == QMI_RpcFutureState.RESULT_IS_EXCEPTION: if not isinstance(self._result, BaseException): - raise QMI_RuntimeException("Received invalid exception value from RPC call") + raise QMI_RuntimeException("Received invalid exception value from RPC call.") raise self._result if self._state == QMI_RpcFutureState.RESULT_IS_VALUE: return self._result elif self._state == QMI_RpcFutureState.OBJECT_IS_LOCKED: - raise QMI_RuntimeException("The object is locked by another proxy") + raise QMI_RuntimeException("The object is locked by another proxy.") else: # No result yet. pass @@ -528,13 +622,27 @@ def wait(self, timeout: float | None = None) -> Any: self._context.unregister_message_handler(self) -def non_blocking_rpc_method_call(context: "qmi.core.context.QMI_Context", - rpc_object_address: QMI_MessageHandlerAddress, - method_name: str, - rpc_lock_token: QMI_LockTokenDescriptor | None, - *args: Any, - **kwargs: Any - ) -> Any: +def rpc_constant_call( + context: "qmi.core.context.QMI_Context", + rpc_object_address: QMI_MessageHandlerAddress, + constant_name: str, + rpc_lock_token: QMI_LockTokenDescriptor | None, + constant_value: Any +) -> Any: + """Helper function that performs a call to change a specific constant of the target RPC object.""" + future = QMI_RpcFuture(context, rpc_object_address, rpc_lock_token) + future.send_constant_rpc_request_message(constant_name, constant_value) + return future.wait() + + +def non_blocking_rpc_method_call( + context: "qmi.core.context.QMI_Context", + rpc_object_address: QMI_MessageHandlerAddress, + method_name: str, + rpc_lock_token: QMI_LockTokenDescriptor | None, + *args: Any, + **kwargs: Any +) -> Any: """Helper function that performs a non-blocking call to a specific method of the target RPC object.""" if "rpc_timeout" in kwargs: raise RuntimeError("rpc_timeout parameter makes no sense for non-blocking invocation.") @@ -544,14 +652,15 @@ def non_blocking_rpc_method_call(context: "qmi.core.context.QMI_Context", return future -def blocking_rpc_method_call(context: "qmi.core.context.QMI_Context", - rpc_object_address: QMI_MessageHandlerAddress, - method_name: str, - rpc_lock_token: QMI_LockTokenDescriptor | None, - *args: Any, - rpc_timeout: float | None = None, - **kwargs: Any - ) -> Any: +def blocking_rpc_method_call( + context: "qmi.core.context.QMI_Context", + rpc_object_address: QMI_MessageHandlerAddress, + method_name: str, + rpc_lock_token: QMI_LockTokenDescriptor | None, + *args: Any, + rpc_timeout: float | None = None, + **kwargs: Any +) -> Any: """Helper function that performs a blocking call to a specific method of the target RPC object.""" future = QMI_RpcFuture(context, rpc_object_address, rpc_lock_token) future.send_method_rpc_request_message(method_name, args, kwargs) @@ -588,8 +697,9 @@ def __init__(self, context: "qmi.core.context.QMI_Context", descriptor: RpcObjec # method name. def make_rpc_forward_function(method_name: str): return lambda self, *args, **kwargs: \ - non_blocking_rpc_method_call(self._context, self._rpc_object_address, method_name, - self._lock_token, *args, **kwargs) + non_blocking_rpc_method_call( + self._context, self._rpc_object_address, method_name, self._lock_token, *args, **kwargs + ) # Add methods. for method_descriptor in descriptor.interface.methods: @@ -597,9 +707,8 @@ def make_rpc_forward_function(method_name: str): method = make_rpc_forward_function(method_descriptor.name) # Update special attributes to make the forward function look like the method it is a proxy for. - docstring = "rpc proxy for {}{} method of {} instance".format(method_descriptor.name, - method_descriptor.signature, - self._rpc_class_fqn) + docstring = f"rpc proxy for {method_descriptor.name}{method_descriptor.signature} method of " +\ + f"{self._rpc_class_fqn} instance" if method_descriptor.docstring: docstring = docstring + "\n\n" + method_descriptor.docstring @@ -630,29 +739,39 @@ def __init__(self, context: "qmi.core.context.QMI_Context", descriptor: RpcObjec self._rpc_class_fqn = ".".join((descriptor.interface.rpc_class_module, descriptor.interface.rpc_class_name)) self._lock_token: QMI_LockTokenDescriptor | None = None - # Helper function used to create a new scope such that each method created in the loop below uses the intended - # method name. - def make_rpc_forward_function(method_name: str): + def make_rpc_constant_forward_function(constant_name: str): + """Helper function used to create a new scope such that each constant in the loop below is assigned to + correct constant name.""" + return lambda self, constant_value: \ + rpc_constant_call( + self._context, self._rpc_object_address, constant_name, self._lock_token, constant_value + ) + + def make_rpc_method_forward_function(method_name: str): + """Helper function used to create a new scope such that each method created in the loop below uses the + intended method name.""" return lambda self, *args, **kwargs: \ - blocking_rpc_method_call(self._context, self._rpc_object_address, method_name, self._lock_token, - *args, **kwargs) + blocking_rpc_method_call( + self._context, self._rpc_object_address, method_name, self._lock_token, *args, **kwargs + ) # Set docstring. setattr(self, "__doc__", descriptor.interface.rpc_class_docstring) # Add constants. for constant_descriptor in descriptor.interface.constants: - setattr(self, constant_descriptor.name, constant_descriptor.value) + constant = make_rpc_constant_forward_function(constant_descriptor.name) + setattr(self, constant_descriptor.name, constant.__get__(self)) # Add methods. for method_descriptor in descriptor.interface.methods: # Generate a function that forward calls to itself to the corresponding RPC method of the peer context. - method = make_rpc_forward_function(method_descriptor.name) + method = make_rpc_method_forward_function(method_descriptor.name) # Update special attributes to make the forward function look like the method it is a proxy for. - docstring = "rpc proxy for {}{} method of {} instance".format(method_descriptor.name, - method_descriptor.signature, - self._rpc_class_fqn) + docstring = f"rpc proxy for {method_descriptor.name}{method_descriptor.signature} method of " +\ + f"{self._rpc_class_fqn} instance." + if method_descriptor.docstring: docstring = docstring + "\n\n" + method_descriptor.docstring @@ -695,7 +814,7 @@ def __enter__(self) -> "QMI_RpcProxy": self.__enter__() return self - def __exit__(self, *args, **kwargs): + def __exit__(self, *args, **kwargs) -> None: self.__exit__() def __repr__(self) -> str: @@ -778,8 +897,10 @@ def unlock(self, lock_token: str | None = None) -> bool: self.rpc_nonblocking._lock_token = None return True else: - _logger.debug("%s unlock with %s denied, locked with %s", self._rpc_object_address, self._lock_token, - their_lock_token) + _logger.debug( + "%s unlock with %s denied, locked with %s", + self._rpc_object_address, self._lock_token, their_lock_token + ) return False def force_unlock(self) -> None: @@ -883,32 +1004,30 @@ def get_category(cls) -> str | None: """ return None - def __init__(self, - context: 'qmi.core.context.QMI_Context', - name: str, - signal_declaration_class: type | None = None - ) -> None: + def __init__( + self, + context: 'qmi.core.context.QMI_Context', + name: str, + signal_declaration_class: type | None = None + ) -> None: """Initialize the object. Instances of QMI_RpcObject are created and managed by the context. They should not normally be instantiated directly by the application. Parameters: - context: Instance of `QMI_Context` that will manage this object. - name: Unique name of this object instance. - signal_declaration_class: Optional separate class which declares - the signals published by this object. + context: Instance of `QMI_Context` that will manage this object. + name: Unique name of this object instance. + signal_declaration_class: Optional separate class which declares the signals published by this object. """ self._context = context self._name = name # Create an RpcObjectDescriptor for this QMI_RpcObject instance. # - # In the general case, the "signal_declaration_class" is identical to - # the "rpc_object_class". This simply means that the class which - # implements the RPC methods, is also the class which declares signals. - # As an exception to this rule, QMI_TaskRunner will specify a different - # signal declaration class. + # In the general case, the "signal_declaration_class" is identical to the "rpc_object_class". This simply + # means that the class which implements the RPC methods, is also the class which declares signals. + # As an exception to this rule, QMI_TaskRunner will specify a different signal declaration class. if signal_declaration_class is None: signal_declaration_class = type(self) @@ -1007,9 +1126,9 @@ def get_signals(self) -> list[SignalDescription]: return list(self._qmi_signals) # type: ignore -def make_interface_descriptor(rpc_object_class: Type[QMI_RpcObject], - signal_declaration_class: Type[QMI_RpcObject] | None = None - ) -> RpcInterfaceDescriptor: +def make_interface_descriptor( + rpc_object_class: Type[QMI_RpcObject], signal_declaration_class: Type[QMI_RpcObject] | None = None +) -> RpcInterfaceDescriptor: """Create a description of the (subset of the) interface of the specified `QMI_RpcObject` subclass that can be accessed via RPC. Signal declarations are taken from the specified signal declaration class, which may be a @@ -1079,10 +1198,11 @@ class _RpcThread(QMI_Thread): should not interact with this class directly. """ - def __init__(self, - context: 'qmi.core.context.QMI_Context', - rpc_object_maker: Callable[[], QMI_RpcObject] - ) -> None: + def __init__( + self, + context: 'qmi.core.context.QMI_Context', + rpc_object_maker: Callable[[], QMI_RpcObject] + ) -> None: super().__init__() self._context = context # We need to know the context, to send replies to RPC requests. self._rpc_object_maker = rpc_object_maker @@ -1090,7 +1210,7 @@ def __init__(self, self._cv = threading.Condition(threading.Lock()) self._fifo: deque = deque() self._rpc_object: QMI_RpcObject | None = None - self._exception: BaseException | None = None + self._exception: BaseException | None = None def _handle_lock_rpc_request(self, request: QMI_LockRpcRequestMessage) -> QMI_LockRpcReplyMessage: """Handle a lock message.""" @@ -1103,13 +1223,15 @@ def _handle_lock_rpc_request(self, request: QMI_LockRpcRequestMessage) -> QMI_Lo # Object was not locked, lock it by storing the provided token. self._locking_token = return_token = request.lock_token _logger.info("%s locked with %s!", self._rpc_object.get_name(), request.lock_token) + elif self._locking_token != request.lock_token: # Object was already locked and lock token does not match. return_token = QMI_LockTokenDescriptor(self._context.name, ACCESS_DENIED_TOKEN_PLACEHOLDER) - _logger.warning("Lock request (token=%s) for %s failed! Already locked with token=%s.", - request.lock_token, - self._rpc_object.get_name(), - self._locking_token) + _logger.warning( + "Lock request (token=%s) for %s failed! Already locked with token=%s.", + request.lock_token, self._rpc_object.get_name(), self._locking_token + ) + else: # Lock token matches, but object is already locked, nothing to do. return_token = self._locking_token @@ -1119,17 +1241,19 @@ def _handle_lock_rpc_request(self, request: QMI_LockRpcRequestMessage) -> QMI_Lo if self._locking_token is None: # Object was not locked, nothing to do. return_token = self._locking_token + elif self._locking_token == request.lock_token: # Lock token matches, unlock by clearing the locking token. self._locking_token = return_token = None _logger.info("%s unlocked with %s!", self._rpc_object.get_name(), request.lock_token) + else: # Lock token does not match. return_token = QMI_LockTokenDescriptor(self._context.name, ACCESS_DENIED_TOKEN_PLACEHOLDER) - _logger.warning("Unlocking request (token=%s) for %s failed! Locked with token=%s.", - request.lock_token, - self._rpc_object.get_name(), - self._locking_token) + _logger.warning( + "Unlocking request (token=%s) for %s failed! Locked with token=%s.", + request.lock_token, self._rpc_object.get_name(), self._locking_token + ) elif request.lock_action == QMI_LockRpcAction.FORCE_RELEASE: # Force release of lock irrespective of requesting proxy. @@ -1156,6 +1280,57 @@ def _handle_lock_rpc_request(self, request: QMI_LockRpcRequestMessage) -> QMI_Lo ) return reply + def _handle_constant_rpc_request(self, request: QMI_ConstantRpcRequestMessage) -> QMI_ConstantRpcReplyMessage: + """Handle RPC constant request.""" + assert self._rpc_object is not None + + # RPC constant call - need to check if the caller may invoke the RPC constant: allowed if the object is not + # locked (token is None) or if the provided lock token matches the locking token. + if self._locking_token is None or self._locking_token == request.lock_token: + # Modify the constant; this can raise an exception or return a result. + try: + constant = self._check_and_modify_constant(request) + result_type = QMI_RpcFutureState.RESULT_IS_VALUE + result = constant + + except BaseException as exception: + _logger.debug("RPC constant modify failed", exc_info=True) + result_type = QMI_RpcFutureState.RESULT_IS_EXCEPTION + result = exception + + else: + _logger.error("%s locked, constant request without lock token is denied.", self._rpc_object._name) + result_type = QMI_RpcFutureState.OBJECT_IS_LOCKED + result = None + + reply = QMI_ConstantRpcReplyMessage( + source_address=request.destination_address, + destination_address=request.source_address, + request_id=request.request_id, + state=result_type, + result=result + ) + return reply + + def _check_and_modify_constant(self, request: QMI_ConstantRpcRequestMessage) -> Any: + """Check if the object has the constant requested and is RPC-able; if so, return it.""" + assert self._rpc_object is not None + + # Check that the constant was marked as RPC constant. + if not hasattr(self._rpc_object, request.constant_name): + raise QMI_UnknownRpcException( + f"Object {request.destination_address.object_id} of type {type(self._rpc_object).__name__}" +\ + f" does not have constant {request.constant_name}." + ) + + constant = getattr(self._rpc_object, request.constant_name) + if not check_value_structures_equal(constant, request.constant_value): + raise QMI_UnknownRpcException("New RPC constant value is of different type or size than original.") + + setattr(self._rpc_object, request.constant_name, request.constant_value) + + return constant + def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QMI_MethodRpcReplyMessage: """Handle RPC method request.""" assert self._rpc_object is not None @@ -1173,8 +1348,9 @@ def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QM _logger.debug("RPC method call failed", exc_info=True) result_type = QMI_RpcFutureState.RESULT_IS_EXCEPTION result = exception + else: - _logger.error("%s locked, method request without lock token is denied", self._rpc_object._name) + _logger.error("%s locked, method request without lock token is denied.", self._rpc_object._name) result_type = QMI_RpcFutureState.OBJECT_IS_LOCKED result = None @@ -1187,16 +1363,16 @@ def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QM ) return reply - def _check_and_get_method(self, request: QMI_MethodRpcRequestMessage): + def _check_and_get_method(self, request: QMI_MethodRpcRequestMessage) -> _T: """Check if the object has the method requested and is RPC callable; if so, return it.""" assert self._rpc_object is not None # Check that the method exists. if not hasattr(self._rpc_object, request.method_name): - raise QMI_UnknownRpcException("Object {} of type {} does not have method {}" - .format(request.destination_address.object_id, - type(self._rpc_object).__name__, - request.method_name)) + raise QMI_UnknownRpcException( + f"Object {request.destination_address.object_id} of type {type(self._rpc_object).__name__}" +\ + f" does not have method {request.method_name}." + ) # Check that the method was marked as RPC-callable. method = getattr(self._rpc_object, request.method_name) @@ -1217,20 +1393,27 @@ def _reject_remaining_requests(self) -> None: request = self._fifo.popleft() # Sanity check (this has already been checked by the RpcObjectManager). - assert isinstance(request, (QMI_MethodRpcRequestMessage, QMI_LockRpcRequestMessage)) + assert isinstance(request, ( + QMI_MethodRpcRequestMessage, QMI_ConstantRpcRequestMessage, QMI_LockRpcRequestMessage + ) + ) # Send error reply for this request. - reply = QMI_ErrorReplyMessage(source_address=request.destination_address, - destination_address=request.source_address, - request_id=request.request_id, - error_msg="") + reply = QMI_ErrorReplyMessage( + source_address=request.destination_address, + destination_address=request.source_address, + request_id=request.request_id, + error_msg="" + ) try: self._context.send_message(reply) + except QMI_MessageDeliveryException: # Ignore errors while sending the error reply. - _logger.debug("Failed to send RPC error reply to %s.%s", - request.source_address.context_id, - request.source_address.object_id) + _logger.debug( + "Failed to send RPC error reply to %s.%s", + request.source_address.context_id, request.source_address.object_id + ) def _request_shutdown(self) -> None: # Notify the thread so that it can end its request loop. @@ -1267,6 +1450,7 @@ def run(self) -> None: rpc_object = self._rpc_object_maker() if not isinstance(rpc_object, QMI_RpcObject): raise TypeError(f"Expecting QMI_RpcObject but got {type(rpc_object)}") + except BaseException as exception: # Initialization failed. Store the exception. _logger.warning("Initialization of RpcObject failed", exc_info=True) @@ -1297,9 +1481,11 @@ def run(self) -> None: request = self._fifo.popleft() # Process request. - reply: QMI_MethodRpcReplyMessage | QMI_LockRpcReplyMessage | None + reply: QMI_MethodRpcReplyMessage | QMI_ConstantRpcReplyMessage | QMI_LockRpcReplyMessage | None if isinstance(request, QMI_MethodRpcRequestMessage): reply = self._handle_method_rpc_request(request) + elif isinstance(request, QMI_ConstantRpcRequestMessage): + reply = self._handle_constant_rpc_request(request) elif isinstance(request, QMI_LockRpcRequestMessage): reply = self._handle_lock_rpc_request(request) else: @@ -1326,6 +1512,7 @@ def run(self) -> None: # Tell RPC object to release resources. try: rpc_object.release_rpc_object() + except BaseException: # Log exceptions during resource release. _logger.exception("Failed to release RPC object") @@ -1357,18 +1544,19 @@ class RpcObjectManager(QMI_MessageHandler): should not interact with this class directly. """ - def __init__(self, - address: QMI_MessageHandlerAddress, - context: 'qmi.core.context.QMI_Context', - rpc_object_maker: Callable[[], QMI_RpcObject] - ) -> None: + def __init__( + self, + address: QMI_MessageHandlerAddress, + context: 'qmi.core.context.QMI_Context', + rpc_object_maker: Callable[[], QMI_RpcObject] + ) -> None: """Initialize the RPC object manager. Parameters: - address: Address of the RPC object. - This instance of `RpcObjectManager` will be registered as - message handler for this address. - context: QMI context in which this RPC object will exist. + address: Address of the RPC object. + This instance of `RpcObjectManager` will be registered as + message handler for this address. + context: QMI context in which this RPC object will exist. rpc_object_maker: Function which creates the actual RPC object instance. """ super().__init__(address) @@ -1390,6 +1578,7 @@ def stop(self) -> None: assert self._rpc_thread is not None with self._stop_lock: self._running = False + self._rpc_thread.shutdown() self._rpc_thread.join() self._rpc_thread = None @@ -1413,18 +1602,22 @@ def make_proxy(self) -> QMI_RpcProxy: def handle_message(self, message: QMI_Message) -> None: """Called when a QMI message is delivered for our RPC object.""" - if not isinstance(message, (QMI_MethodRpcRequestMessage, QMI_LockRpcRequestMessage)): - _logger.error("Received unknown message type %r from %s.%s", - type(message), - message.source_address.context_id, - message.source_address.object_id) + if not isinstance(message, ( + QMI_MethodRpcRequestMessage, QMI_ConstantRpcRequestMessage, QMI_LockRpcRequestMessage + ) + ): + _logger.error( + "Received unknown message type %r from %s.%s", + type(message), message.source_address.context_id, message.source_address.object_id + ) return with self._stop_lock: # Reject message if the object is already stopped (or stopping). if not self._running: - raise QMI_MessageDeliveryException("RPC object {}.{} already stopped" - .format(self.address.context_id, self.address.object_id)) + raise QMI_MessageDeliveryException( + f"RPC object {self.address.context_id}.{self.address.object_id} already stopped." + ) # The thread is still running, so we can safely push the message to the thread. assert self._rpc_thread is not None diff --git a/qmi/core/util.py b/qmi/core/util.py index 71f5f8fe..63de7241 100644 --- a/qmi/core/util.py +++ b/qmi/core/util.py @@ -2,6 +2,7 @@ import re import threading +from typing import Any def is_valid_object_name(name: str) -> bool: @@ -82,6 +83,69 @@ def parse_address_and_port(address: str) -> tuple[str, int]: return (host, port) +def check_value_structures_equal(value1: Any, value2: Any) -> bool: + """Check if two values have matching types and container structure. + + Scalar values must have exactly the same type. Lists, tuples and sets must + have the same container type, length, and compatible element types. + Dictionaries must have the same keys and compatible value types. + """ + + if type(value1) is not type(value2): # noqa: E721 + return False + + if isinstance(value1, dict): + if len(value1) != len(value2): + return False + if set(value1) != set(value2): + return False + return all(check_value_structures_equal(value1[key], value2[key]) for key in value1) + + if isinstance(value1, (list, tuple)): + if len(value1) != len(value2): + return False + return all( + check_value_structures_equal(item1, item2) + for item1, item2 in zip(value1, value2, strict=True) + ) + + if isinstance(value1, set): + if len(value1) != len(value2): + return False + value2_signatures = [_make_value_structure_signature(item) for item in value2] + for item in value1: + signature = _make_value_structure_signature(item) + try: + value2_signatures.remove(signature) + except ValueError: + return False + return True + + return True + + +def _make_value_structure_signature(value: Any) -> Any: + """Return a hashable signature for set element structure comparisons.""" + + if isinstance(value, dict): + return ( + dict, + tuple( + sorted( + ((key, _make_value_structure_signature(sub_value)) for key, sub_value in value.items()), + key=repr + ) + ) + ) + if isinstance(value, list): + return (list, tuple(_make_value_structure_signature(item) for item in value)) + if isinstance(value, tuple): + return (tuple, tuple(_make_value_structure_signature(item) for item in value)) + if isinstance(value, set): + return (set, tuple(sorted((_make_value_structure_signature(item) for item in value), key=repr))) + return type(value) + + class AtomicCounter: """Simple atomic counter.""" diff --git a/qmi/instruments/dummy/noisy_sine_generator.py b/qmi/instruments/dummy/noisy_sine_generator.py index e059cfc4..3a0e2f1c 100644 --- a/qmi/instruments/dummy/noisy_sine_generator.py +++ b/qmi/instruments/dummy/noisy_sine_generator.py @@ -10,7 +10,20 @@ class NoisySineGenerator(QMI_Instrument): - """Simulated instrument, useful for testing.""" + """Simulated instrument, useful for testing. + + Attributes: + max_frequency: Maximum allowed frequency that can be set. + max_amplitude: Maximum allowed amplitude that can be set. + max_wait: Maximum wait time duration. + """ + + _rpc_constants = ["max_frequency", "max_amplitude", "max_wait"] + + max_frequency: float = 1e6 + max_amplitude: float = 1e3 + max_wait: float = 10.0 + max_noise: float = max_amplitude def __init__(self, context: QMI_Context, name: str) -> None: super().__init__(context, name) @@ -26,9 +39,10 @@ def set_frequency(self, value: float) -> None: value: The frequency value (unitless). """ self._check_is_open() - valid = isinstance(value, float) and math.isfinite(value) and value >= 0.0 + valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value < self.max_frequency if not valid: raise ValueError("Bad value for frequency: {!r}".format(value)) + self.frequency = value @rpc_method @@ -49,7 +63,7 @@ def set_amplitude(self, value: float) -> None: value: The new amplitude (unitless). """ self._check_is_open() - valid = isinstance(value, float) and math.isfinite(value) and value >= 0.0 + valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value < self.max_amplitude if not valid: raise ValueError("Bad value for amplitude: {!r}".format(value)) @@ -73,9 +87,10 @@ def set_noise(self, value: float) -> None: value: The new noise level (unitless). """ self._check_is_open() - valid = isinstance(value, float) and math.isfinite(value) and value >= 0.0 + valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value < self.max_noise if not valid: raise ValueError("Bad value for noise: {!r}".format(value)) + self.noise = value @rpc_method @@ -96,8 +111,8 @@ def wait(self, duration: float) -> None: duration: The wait duration in seconds. """ self._check_is_open() - if duration < 0.0: - raise ValueError("Bad value for duration: {!r}".format(duration)) + if not 0.0 < duration < self.max_wait: + raise ValueError("Bad value for wait duration: {!r}".format(duration)) time.sleep(duration) diff --git a/tests/core/test_util.py b/tests/core/test_util.py new file mode 100644 index 00000000..e97814ba --- /dev/null +++ b/tests/core/test_util.py @@ -0,0 +1,56 @@ +"""Test core utility functions.""" + +import unittest + +from qmi.core.util import check_value_structures_equal + + +class TestCheckValueStructuresEqual(unittest.TestCase): + """Test structural value type checks.""" + + def test_scalar_values(self) -> None: + """Scalars only need to have the same type.""" + self.assertTrue(check_value_structures_equal(True, False)) + self.assertTrue(check_value_structures_equal("foo", "bar")) + self.assertTrue(check_value_structures_equal(1.0, 2.0)) + self.assertTrue(check_value_structures_equal(1, 2)) + + self.assertFalse(check_value_structures_equal(True, 1)) + self.assertFalse(check_value_structures_equal(1, 1.0)) + self.assertFalse(check_value_structures_equal("1", 1)) + + def test_lists_and_tuples(self) -> None: + """Sequences must match type, length and item structure.""" + self.assertTrue(check_value_structures_equal([1, "a", 1.0], [2, "b", 2.0])) + self.assertTrue(check_value_structures_equal((1, ["a"]), (2, ["b"]))) + + self.assertFalse(check_value_structures_equal([1, "a"], [2])) + self.assertFalse(check_value_structures_equal([1, "a"], [2, 3])) + self.assertFalse(check_value_structures_equal([1, "a"], (2, "b"))) + self.assertFalse(check_value_structures_equal((1, ["a"]), (2, ("b",)))) + + def test_sets(self) -> None: + """Sets must match type, length and item structure.""" + self.assertTrue(check_value_structures_equal({1, "a", 1.5}, {2, "b", 2.5})) + self.assertTrue(check_value_structures_equal({(1, "a"), (2, "b")}, {(3, "c"), (4, "d")})) + + self.assertFalse(check_value_structures_equal({1, "a"}, {2})) + self.assertFalse(check_value_structures_equal({1, "a"}, {2, 3})) + self.assertFalse(check_value_structures_equal({(1, "a")}, {(2, 3)})) + + def test_dictionaries(self) -> None: + """Dictionaries must match type, keys and value structure.""" + self.assertTrue( + check_value_structures_equal( + {"a": 1, "b": ["x", 2.0]}, + {"a": 2, "b": ["y", 3.0]} + ) + ) + + self.assertFalse(check_value_structures_equal({"a": 1}, {"b": 1})) + self.assertFalse(check_value_structures_equal({"a": 1}, {"a": "1"})) + self.assertFalse(check_value_structures_equal({"a": [1]}, {"a": (1,)})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/instruments/dummy/__init__.py b/tests/instruments/dummy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/instruments/dummy/test_noisy_sine_generator.py b/tests/instruments/dummy/test_noisy_sine_generator.py new file mode 100644 index 00000000..b10b8582 --- /dev/null +++ b/tests/instruments/dummy/test_noisy_sine_generator.py @@ -0,0 +1,188 @@ +"""Unit tests for Dummy Noisy Sine Generator.""" +import time +from typing import cast +import unittest +from unittest.mock import Mock, patch + +import qmi +from qmi.instruments.dummy import Dummy_NoisySineGenerator as NSG +from qmi.core.exceptions import QMI_InvalidOperationException, QMI_UnknownRpcException + +from tests.patcher import PatcherQmiContext as QMI_Context + + +class TestNsgOpenClose(unittest.TestCase): + """Open close test for Dummy NSG.""" + + def setUp(self): + self.ctx = QMI_Context("dummy") + self.ctx.start() + self.nsg = NSG(self.ctx, "nsg") + + def tearDown(self): + self.ctx.stop() + + def test_open_close(self): + """Test the basic open-close functions and logic.""" + self.assertFalse(self.nsg.is_open()) + + self.nsg.open() + + self.assertTrue(self.nsg.is_open()) + + with self.assertRaises(QMI_InvalidOperationException): + self.nsg.open() + + self.nsg.close() + + self.assertFalse(self.nsg.is_open()) + + with self.assertRaises(QMI_InvalidOperationException): + self.nsg.close() + + def test_function_call_excepts_if_not_open(self): + """Test that an error is raised if any other method is called before 'open'.""" + with self.assertRaises(QMI_InvalidOperationException): + self.nsg.get_noise() + + self.nsg.open() + self.nsg.get_noise() + + +class TestNsgFunctions(unittest.TestCase): + """Tests for Dummy NSG methods and constants.""" + + def setUp(self): + qmi.start("dummy", None) + NSG.max_wait = 0.1 + self.nsg = qmi.make_instrument("nsg", NSG) + self.nsg.open() + + def tearDown(self): + self.nsg.close() + qmi.stop() + + def test_frequency_setting(self): + """Test that frequency can be set.""" + expected_freq = 10.0 + initial_freq = self.nsg.get_frequency() + + self.nsg.set_frequency(expected_freq) + + new_freq = self.nsg.get_frequency() + + self.assertNotEqual(initial_freq, new_freq) + self.assertEqual(expected_freq, new_freq) + + def test_frequency_set_excepts(self): + """Test frequency setting excepts at invalid values.""" + invalid_freqs = [-1.0, NSG.max_frequency + 1.0] + for invalid_freq in invalid_freqs: + with self.assertRaises(ValueError): + self.nsg.set_frequency(invalid_freq) + + def test_max_frequency_change(self): + """Test that _rpc_constants 'max_frequency' constant can be manipulated.""" + invalid_freq = NSG.max_frequency + 1.0 + new_max = NSG.max_frequency + 2.0 + + with self.assertRaises(ValueError): + self.nsg.set_frequency(invalid_freq) + + self.nsg.max_frequency(new_max) + self.nsg.set_frequency(invalid_freq) + new_freq = self.nsg.get_frequency() + + self.assertEqual(invalid_freq, new_freq) + + def test_amplitude_setting(self): + """Test that amplitude can be set.""" + expected_amp = 10.0 + initial_amp = self.nsg.get_amplitude() + + self.nsg.set_amplitude(expected_amp) + + new_amp = self.nsg.get_amplitude() + + self.assertNotEqual(initial_amp, new_amp) + self.assertEqual(expected_amp, new_amp) + + def test_amplitude_set_excepts(self): + """Test amplitude setting excepts at invalid values.""" + invalid_amps = [-1.0, NSG.max_amplitude + 1.0] + for invalid_amp in invalid_amps: + with self.assertRaises(ValueError): + self.nsg.set_amplitude(invalid_amp) + + def test_max_amplitude_change(self): + """Test that _rpc_constants 'max_amplitude' constant can be manipulated.""" + invalid_amp = NSG.max_amplitude + 1.0 + new_max = NSG.max_amplitude + 2.0 + + with self.assertRaises(ValueError): + self.nsg.set_amplitude(invalid_amp) + + self.nsg.max_amplitude(new_max) + self.nsg.set_amplitude(invalid_amp) + new_amp = self.nsg.get_amplitude() + + self.assertEqual(invalid_amp, new_amp) + + def test_noise_setting(self): + """Test that noise can be set.""" + expected_noise = 10.0 + initial_noise = self.nsg.get_noise() + + self.nsg.set_noise(expected_noise) + + new_noise = self.nsg.get_noise() + + self.assertNotEqual(initial_noise, new_noise) + self.assertEqual(expected_noise, new_noise) + + def test_noise_set_excepts(self): + """Test noise setting excepts at invalid values.""" + invalid_noises = [-1.0, NSG.max_noise + 1.0] + for invalid_noise in invalid_noises: + with self.assertRaises(ValueError): + self.nsg.set_noise(invalid_noise) + + def test_max_noise_not_modifiable(self): + """Test that 'max_noise' cannot be changed as it is not in _rpc_constants.""" + with self.assertRaises(AttributeError): + self.nsg.max_noise(1.0) + + def test_wait_setting(self): + """Test that wait waits.""" + expected_wait = 0.01 + + start = time.time() + self.nsg.wait(expected_wait) + end = time.time() + + self.assertGreaterEqual(end - start, expected_wait) + + def test_wait_excepts(self): + """Test wait excepts at invalid values.""" + invalid_waits = [-1.0, NSG.max_wait + 1.0] + for invalid_wait in invalid_waits: + with self.assertRaises(ValueError): + self.nsg.wait(invalid_wait) + + def test_max_wait_change(self): + """Test that _rpc_constants 'max_wait' constant can be manipulated.""" + invalid_wait = NSG.max_wait + 0.1 + new_max = NSG.max_wait + 0.2 + + with self.assertRaises(ValueError): + self.nsg.wait(invalid_wait) + + self.nsg.max_wait(new_max) + self.nsg.wait(invalid_wait) + + def test_get_sample(self): + """Test get_sample, happy flow.""" + value = self.nsg.get_sample() + + self.assertGreater(value, -2.0 * NSG.max_amplitude) + self.assertLess(value, 2.0 * NSG.max_amplitude) From 8563565fe8df17427278fc2780b57ddabac2c055 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Tue, 19 May 2026 17:17:17 +0200 Subject: [PATCH 02/36] [changing-rpc-constants-on-proxy] mypy fixes. --- qmi/core/rpc.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index c30eb5d2..f4156c6c 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -1340,7 +1340,7 @@ def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QM if self._locking_token is None or self._locking_token == request.lock_token: # Invoke the method; this can raise an exception or return a result. try: - method = self._check_and_get_method(request) + method: Callable = self._check_and_get_method(request) result_type = QMI_RpcFutureState.RESULT_IS_VALUE result = method(*request.method_args, **request.method_kwargs) @@ -1363,7 +1363,7 @@ def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QM ) return reply - def _check_and_get_method(self, request: QMI_MethodRpcRequestMessage) -> _T: + def _check_and_get_method(self, request: QMI_MethodRpcRequestMessage) -> Callable: """Check if the object has the method requested and is RPC callable; if so, return it.""" assert self._rpc_object is not None @@ -1519,7 +1519,10 @@ def run(self) -> None: _logger.debug("Stopping RPC thread") - def push_rpc_request(self, rpc_request: QMI_MethodRpcRequestMessage | QMI_LockRpcRequestMessage | None) -> None: + def push_rpc_request( + self, + rpc_request: QMI_MethodRpcRequestMessage | QMI_ConstantRpcRequestMessage | QMI_LockRpcRequestMessage | None + ) -> None: """Push an RPC request into the request queue and notify the thread.""" with self._cv: self._fifo.append(rpc_request) From 40ee94d55a46f147b070292b30ae724119a7849d Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Tue, 19 May 2026 17:37:19 +0200 Subject: [PATCH 03/36] [changing-rpc-constants-on-proxy] Trying to make RPC constant call without a new value to return the current value. --- qmi/core/rpc.py | 3 +++ qmi/instruments/bristol/bristol_871a.py | 4 +++- qmi/instruments/cobolt/laser_06_01.py | 2 +- qmi/instruments/newport/ag_uc8.py | 10 +++++----- qmi/instruments/newport/newport_843r.py | 2 +- qmi/instruments/pi/e873.py | 2 +- tests/core/test_rpc.py | 10 +++++----- 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index f4156c6c..c30aecb4 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -1324,6 +1324,9 @@ def _check_and_modify_constant(self, request: QMI_ConstantRpcRequestMessage) -> ) constant = getattr(self._rpc_object, request.constant_name) + if request.constant_value is None: + return constant + if not check_value_structures_equal(constant, request.constant_value): raise QMI_UnknownRpcException("New RPC constant value is of different type or size than original.") diff --git a/qmi/instruments/bristol/bristol_871a.py b/qmi/instruments/bristol/bristol_871a.py index 9041814f..fcd480c9 100644 --- a/qmi/instruments/bristol/bristol_871a.py +++ b/qmi/instruments/bristol/bristol_871a.py @@ -148,7 +148,7 @@ class Bristol_871A(QMI_Instrument): STATUS_GOOD: Status value of a wavelength measurement without any issues. """ - _rpc_constants = ["CONDITION_BITS", "STATUS_BITS", "STATUS_MASK", "STATUS_GOOD"] + _rpc_constants = ["RESPONSE_TIMEOUT", "STATUS_MASK", "STATUS_GOOD"] # Condition codes returned by get_condition(), by bit index. CONDITION_BITS = { @@ -209,11 +209,13 @@ def __init__( """Initialize the instrument driver. Parameters: + context: The parent QMI context. name: Name for this instrument instance. scpi_transport: QMI transport descriptor for the SCPI channel. If not specified, the SCPI channel will not be opened. serial_transport: QMI transport descriptor for the serial output channel. If not specified, the serial channel will not be opened. + queue_size: Optional 'deque' queue size parameter. Default is 10000 measurements. Raises: QMI_UsageException: At least one of scpi_transport or serial_transport must be specified. diff --git a/qmi/instruments/cobolt/laser_06_01.py b/qmi/instruments/cobolt/laser_06_01.py index f1dba016..0e18801f 100644 --- a/qmi/instruments/cobolt/laser_06_01.py +++ b/qmi/instruments/cobolt/laser_06_01.py @@ -16,7 +16,7 @@ class Cobolt_Laser_06_01(QMI_Instrument): """Instrument driver for the Cobolt 06-01 series diode laser.""" - _rpc_constants = ["FAULT_CODES", "OPERATING_MODES"] + _rpc_constants = ["RESPONSE_TIMEOUT"] # Fault codes returned by get_operating_fault(). FAULT_CODES = { diff --git a/qmi/instruments/newport/ag_uc8.py b/qmi/instruments/newport/ag_uc8.py index a3f2320e..8a94f8db 100644 --- a/qmi/instruments/newport/ag_uc8.py +++ b/qmi/instruments/newport/ag_uc8.py @@ -29,7 +29,9 @@ class AxisStatus(enum.IntEnum): class Newport_AG_UC8(QMI_Instrument): """Instrument driver for the Newport AG-UC8 Piezo Stepper Controller.""" - _rpc_constants = ["ERROR_CODES", "SPEED_TABLE"] + _rpc_constants = [ + "RESPONSE_TIMEOUT", "SLOW_RESPONSE_TIMEOUT", "COMMAND_DELAY", "CHANNEL_SWITCH_DELAY", "RESET_DELAY" + ] # Meaning of error codes returned by the device. ERROR_CODES = { @@ -53,8 +55,7 @@ class Newport_AG_UC8(QMI_Instrument): # By default, expect response to command within 1 second. RESPONSE_TIMEOUT = 1.0 - # Some commands (position measurement and absolute move) can take - # up to 2 minutes to complete. + # Some commands (position measurement and absolute move) can take up to 2 minutes to complete. SLOW_RESPONSE_TIMEOUT = 120.0 # After a command which does not generate a response, a short @@ -64,8 +65,7 @@ class Newport_AG_UC8(QMI_Instrument): # Delay after changing channels. CHANNEL_SWITCH_DELAY = 0.02 - # After a reset command, a longer delay is needed before - # we can send the following command. + # After a reset command, a longer delay is needed before we can send the following command. RESET_DELAY = 0.05 def __init__(self, context: QMI_Context, name: str, transport: str) -> None: diff --git a/qmi/instruments/newport/newport_843r.py b/qmi/instruments/newport/newport_843r.py index 36c1893e..974323d7 100644 --- a/qmi/instruments/newport/newport_843r.py +++ b/qmi/instruments/newport/newport_843r.py @@ -29,7 +29,7 @@ class Newport_843R(QMI_Instrument): """Instrument driver for the Newport 843-R optical power meter.""" - _rpc_constants = ["SENSOR_TYPES"] + _rpc_constants = ["COMMAND_DELAY"] # Meaning of sensor type codes returned by the instrument. SENSOR_TYPES = { diff --git a/qmi/instruments/pi/e873.py b/qmi/instruments/pi/e873.py index 75c7ac4b..f9f7a87e 100644 --- a/qmi/instruments/pi/e873.py +++ b/qmi/instruments/pi/e873.py @@ -58,7 +58,7 @@ class ReferenceSignalMode(enum.Enum): class PI_E873(QMI_Instrument): """Instrument driver for the PhysikInstrumente E-873 Servo Controller.""" - _rpc_constants = ["ERROR_CODES"] + _rpc_constants = ["RESPONSE_TIMEOUT"] # Error codes returned by instrument via ERR? query. ERROR_CODES = GCS_CONTROLLER_ERROR_CODES diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index aa85353d..6f0cfced 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -384,14 +384,14 @@ def test_constants(self): proxy2 = self.c2.get_rpc_object_by_name("c1.tc1") # Check that constants are accessible via both proxies. - self.assertEqual(proxy1.CONSTANT_NUMBER, 42) - self.assertEqual(proxy1.CONSTANT_STRING, "testing") - self.assertEqual(proxy2.CONSTANT_NUMBER, 42) - self.assertEqual(proxy2.CONSTANT_STRING, "testing") + self.assertEqual(proxy1.CONSTANT_NUMBER(), 42) + self.assertEqual(proxy1.CONSTANT_STRING(), "testing") + self.assertEqual(proxy2.CONSTANT_NUMBER(), 42) + self.assertEqual(proxy2.CONSTANT_STRING(), "testing") # Check that non-exported constants are not accessible. with self.assertRaises(AttributeError): - print(proxy1.CONSTANT_FLOAT) + proxy1.CONSTANT_FLOAT() def test_call_to_disconnected(self): From 2cfb6c635bd75486b1bc2328f4be14ccbd1a3e79 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Wed, 20 May 2026 11:58:07 +0200 Subject: [PATCH 04/36] [changing-rpc-constants-on-proxy] Changed the forwarded proxy constant value calls to be settables and gettables instead, so that they can be modified like any class attribute. --- CHANGELOG.md | 3 +- bin/instruments/qmi_anapico_apsin | 2 +- bin/instruments/qmi_bristol_fos | 2 +- bin/instruments/qmi_highfinesse_ws | 2 +- bin/instruments/qmi_mcc_usb1808x | 2 +- bin/instruments/qmi_newport_ag_uc8 | 2 +- bin/instruments/qmi_quantum_composer_9530 | 2 +- bin/instruments/qmi_siglent_ssa3000x | 2 +- bin/instruments/qmi_srs_dc205 | 2 +- bin/instruments/qmi_tenma_72psu | 2 +- bin/instruments/qmi_teraxion_tfn | 2 +- bin/instruments/qmi_thorlabs_k10cr1 | 2 +- bin/instruments/qmi_timebase_dim3000 | 2 +- bin/instruments/qmi_wavelength_tclab | 2 +- bin/instruments/qmi_wieserlabs_flexdds | 2 +- qmi/core/rpc.py | 94 +++++++++++++------ qmi/instruments/dummy/noisy_sine_generator.py | 9 +- tests/core/test_rpc.py | 17 +++- .../dummy/test_noisy_sine_generator.py | 8 +- 19 files changed, 104 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d30644..a201f59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.54.0-beta.0] - Unreleased ### Added -- Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects. +- Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects from their proxies. +- In `qmi.core.util` added functions for checking that two values are of same type or size and type if the type is an iterable (excluding string). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_constants`. ## [0.53.0] - 2026-05-11 diff --git a/bin/instruments/qmi_anapico_apsin b/bin/instruments/qmi_anapico_apsin index c58174c4..d2861f1c 100644 --- a/bin/instruments/qmi_anapico_apsin +++ b/bin/instruments/qmi_anapico_apsin @@ -8,7 +8,7 @@ import time import qmi from qmi.instruments.anapico.apsin import Anapico_APSIN -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_bristol_fos b/bin/instruments/qmi_bristol_fos index 45991fd6..166b5bde 100644 --- a/bin/instruments/qmi_bristol_fos +++ b/bin/instruments/qmi_bristol_fos @@ -6,7 +6,7 @@ from contextlib import nullcontext, AbstractContextManager, ExitStack import qmi from qmi.instruments.bristol import Bristol_Fos -from qmi.utils.context_managers import start_stop + def run() -> None: diff --git a/bin/instruments/qmi_highfinesse_ws b/bin/instruments/qmi_highfinesse_ws index 52513644..046f1043 100644 --- a/bin/instruments/qmi_highfinesse_ws +++ b/bin/instruments/qmi_highfinesse_ws @@ -21,7 +21,7 @@ from collections.abc import Callable import qmi from qmi.core.exceptions import QMI_InstrumentException from qmi.instruments.high_finesse.wlm import HighFinesse_Wlm -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_mcc_usb1808x b/bin/instruments/qmi_mcc_usb1808x index 4203cf52..0977c836 100644 --- a/bin/instruments/qmi_mcc_usb1808x +++ b/bin/instruments/qmi_mcc_usb1808x @@ -7,7 +7,7 @@ import sys import qmi from qmi.instruments.mcc.usb1808x import MCC_USB1808X -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_newport_ag_uc8 b/bin/instruments/qmi_newport_ag_uc8 index 63754673..981924e1 100644 --- a/bin/instruments/qmi_newport_ag_uc8 +++ b/bin/instruments/qmi_newport_ag_uc8 @@ -7,7 +7,7 @@ import sys import qmi from qmi.instruments.newport.ag_uc8 import Newport_AG_UC8, AxisStatus -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close CHANNELS = [1, 2, 3, 4] diff --git a/bin/instruments/qmi_quantum_composer_9530 b/bin/instruments/qmi_quantum_composer_9530 index eb1ab730..f7e52061 100644 --- a/bin/instruments/qmi_quantum_composer_9530 +++ b/bin/instruments/qmi_quantum_composer_9530 @@ -10,7 +10,7 @@ from qmi.core.exceptions import QMI_ApplicationException from qmi.instruments.quantum_composers.pulse_generator9530 import ( RefClkSource, PulseMode, TriggerMode, TriggerEdge, OutputDriver, QuantumComposers_PulseGenerator9530) -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_siglent_ssa3000x b/bin/instruments/qmi_siglent_ssa3000x index 0b7ce085..b08066a9 100644 --- a/bin/instruments/qmi_siglent_ssa3000x +++ b/bin/instruments/qmi_siglent_ssa3000x @@ -7,7 +7,7 @@ import sys import qmi from qmi.instruments.siglent.ssa3000x import SSA3000X -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_srs_dc205 b/bin/instruments/qmi_srs_dc205 index aac61919..953559ab 100644 --- a/bin/instruments/qmi_srs_dc205 +++ b/bin/instruments/qmi_srs_dc205 @@ -6,7 +6,7 @@ import sys import qmi from qmi.instruments.stanford_research_systems.dc205 import SRS_DC205 -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_tenma_72psu b/bin/instruments/qmi_tenma_72psu index 0975cabe..e66373d2 100644 --- a/bin/instruments/qmi_tenma_72psu +++ b/bin/instruments/qmi_tenma_72psu @@ -7,7 +7,7 @@ import sys import qmi from qmi.instruments.tenma import * # This may look like unused in IDE but will import all PSU classes. -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_teraxion_tfn b/bin/instruments/qmi_teraxion_tfn index 1b744449..2be42683 100644 --- a/bin/instruments/qmi_teraxion_tfn +++ b/bin/instruments/qmi_teraxion_tfn @@ -6,7 +6,7 @@ import sys import qmi from qmi.instruments.teraxion.tfn import Teraxion_TFN, Teraxion_TFNElement -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_thorlabs_k10cr1 b/bin/instruments/qmi_thorlabs_k10cr1 index d853ca9f..78b36a12 100644 --- a/bin/instruments/qmi_thorlabs_k10cr1 +++ b/bin/instruments/qmi_thorlabs_k10cr1 @@ -7,7 +7,7 @@ import sys import qmi from qmi.instruments.thorlabs.k10cr1 import Thorlabs_K10CR1 -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_timebase_dim3000 b/bin/instruments/qmi_timebase_dim3000 index 74edec08..c866380c 100644 --- a/bin/instruments/qmi_timebase_dim3000 +++ b/bin/instruments/qmi_timebase_dim3000 @@ -8,7 +8,7 @@ import time import qmi from qmi.instruments.timebase.dim3000 import TimeBase_DIM3000, DIM3000SweepMode, DIM3000FMDeviation -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_wavelength_tclab b/bin/instruments/qmi_wavelength_tclab index d38d370d..30d1179c 100644 --- a/bin/instruments/qmi_wavelength_tclab +++ b/bin/instruments/qmi_wavelength_tclab @@ -8,7 +8,7 @@ import time import qmi from qmi.instruments.wavelength.tclab import Wavelength_TC_Lab -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/bin/instruments/qmi_wieserlabs_flexdds b/bin/instruments/qmi_wieserlabs_flexdds index f881ffed..77bda572 100644 --- a/bin/instruments/qmi_wieserlabs_flexdds +++ b/bin/instruments/qmi_wieserlabs_flexdds @@ -7,7 +7,7 @@ import sys import qmi from qmi.instruments.wieserlabs.flexdds import Wieserlabs_FlexDDS_NG_Dual, OutputChannel -from qmi.utils.context_managers import start_stop, open_close +from qmi.utils.context_managers import open_close def run() -> int: diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index c30aecb4..d2197d6c 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -330,16 +330,17 @@ def __init__( class QMI_ConstantRpcRequestMessage(QMI_RequestMessage): - """Message sent by an RPC client to change a RPC constant value of a remote object. + """Message sent by an RPC client to get or change a RPC constant value of a remote object. See `QMI_ConstantRpcReplyMessage` for how to interpret the reply to a request. Attributes: - constant_name: The constant name. - constant_value: The value or values of the constants. - lock_token: The unique token to use for the lock. + constant_name: The constant name. + constant_value: The new constant value. + set_constant_value: True to set the constant, False to get the current value. + lock_token: The unique token to use for the lock. """ - __slots__ = ("constant_name", "constant_value", "lock_token") + __slots__ = ("constant_name", "constant_value", "set_constant_value", "lock_token") def __init__( self, @@ -347,11 +348,13 @@ def __init__( destination_address: QMI_MessageHandlerAddress, constant_name: str, constant_value: Any, + set_constant_value: bool, lock_token: QMI_LockTokenDescriptor | None = None ) -> None: super().__init__(source_address, destination_address) self.constant_name = constant_name self.constant_value = constant_value + self.set_constant_value = set_constant_value self.lock_token = lock_token @@ -490,19 +493,22 @@ def send_method_rpc_request_message( def send_constant_rpc_request_message( self, rpc_constant_name: str, - rpc_constant_value: Any + rpc_constant_value: Any, + set_constant_value: bool ) -> None: - """Send a request message to the RPC object to modify the specified constant. + """Send a request message to the RPC object to get or modify the specified constant. Parameters: rpc_constant_name: Name of the constant to modify. rpc_constant_value: The new constant value. + set_constant_value: True to modify the constant, False to get its current value. """ request = QMI_ConstantRpcRequestMessage( self.address, self.rpc_object_address, rpc_constant_name, rpc_constant_value, + set_constant_value, self.lock_token ) @@ -627,11 +633,13 @@ def rpc_constant_call( rpc_object_address: QMI_MessageHandlerAddress, constant_name: str, rpc_lock_token: QMI_LockTokenDescriptor | None, - constant_value: Any + constant_value: Any = None, + *, + set_constant_value: bool = False ) -> Any: - """Helper function that performs a call to change a specific constant of the target RPC object.""" + """Helper function that performs a call to get or change a specific constant of the target RPC object.""" future = QMI_RpcFuture(context, rpc_object_address, rpc_lock_token) - future.send_constant_rpc_request_message(constant_name, constant_value) + future.send_constant_rpc_request_message(constant_name, constant_value, set_constant_value) return future.wait() @@ -738,14 +746,9 @@ def __init__(self, context: "qmi.core.context.QMI_Context", descriptor: RpcObjec self._rpc_object_address = descriptor.address self._rpc_class_fqn = ".".join((descriptor.interface.rpc_class_module, descriptor.interface.rpc_class_name)) self._lock_token: QMI_LockTokenDescriptor | None = None - - def make_rpc_constant_forward_function(constant_name: str): - """Helper function used to create a new scope such that each constant in the loop below is assigned to - correct constant name.""" - return lambda self, constant_value: \ - rpc_constant_call( - self._context, self._rpc_object_address, constant_name, self._lock_token, constant_value - ) + self._rpc_constant_names = frozenset( + constant_descriptor.name for constant_descriptor in descriptor.interface.constants + ) def make_rpc_method_forward_function(method_name: str): """Helper function used to create a new scope such that each method created in the loop below uses the @@ -758,11 +761,6 @@ def make_rpc_method_forward_function(method_name: str): # Set docstring. setattr(self, "__doc__", descriptor.interface.rpc_class_docstring) - # Add constants. - for constant_descriptor in descriptor.interface.constants: - constant = make_rpc_constant_forward_function(constant_descriptor.name) - setattr(self, constant_descriptor.name, constant.__get__(self)) - # Add methods. for method_descriptor in descriptor.interface.methods: # Generate a function that forward calls to itself to the corresponding RPC method of the peer context. @@ -794,9 +792,49 @@ def make_rpc_method_forward_function(method_name: str): ) setattr(self, signal_descriptor.name, subscriber) - # Add non-blocking proxy. + # Add non-blocking proxy. Must be added last in __init__. self.rpc_nonblocking = QMI_RpcNonBlockingProxy(context, descriptor) + def __getattribute__(self, name: str) -> Any: + rpc_constant_names = object.__getattribute__(self, "_rpc_constant_names") + if name in rpc_constant_names: + return rpc_constant_call( + object.__getattribute__(self, "_context"), + object.__getattribute__(self, "_rpc_object_address"), + name, + object.__getattribute__(self, "_lock_token") + ) + + return object.__getattribute__(self, name) + + def __setattr__(self, name: str, value: Any) -> None: + try: + initialized = object.__getattribute__(self, "rpc_nonblocking") + except AttributeError: + initialized = False + + if initialized: + rpc_constant_names = object.__getattribute__(self, "_rpc_constant_names") + if name in rpc_constant_names: + rpc_constant_call( + object.__getattribute__(self, "_context"), + object.__getattribute__(self, "_rpc_object_address"), + name, + object.__getattribute__(self, "_lock_token"), + value, + set_constant_value=True + ) + return + + elif name == "_lock_token": + # Lock token should always be allowed to be set + object.__setattr__(self, name, value) + + else: + raise AttributeError("Not allowed to set new attributes on a proxy class.") + + object.__setattr__(self, name, value) + def __enter__(self) -> "QMI_RpcProxy": """The context manager definition is needed for the proxy as it will always be returned from QMI contexts, instead of the actual RPC object instance. Trying to use context management directly on this class will @@ -1324,15 +1362,15 @@ def _check_and_modify_constant(self, request: QMI_ConstantRpcRequestMessage) -> ) constant = getattr(self._rpc_object, request.constant_name) - if request.constant_value is None: + if not request.set_constant_value: return constant - + if not check_value_structures_equal(constant, request.constant_value): raise QMI_UnknownRpcException("New RPC constant value is of different type or size than original.") setattr(self._rpc_object, request.constant_name, request.constant_value) - - return constant + + return getattr(self._rpc_object, request.constant_name) def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QMI_MethodRpcReplyMessage: """Handle RPC method request.""" diff --git a/qmi/instruments/dummy/noisy_sine_generator.py b/qmi/instruments/dummy/noisy_sine_generator.py index 3a0e2f1c..1f4989c9 100644 --- a/qmi/instruments/dummy/noisy_sine_generator.py +++ b/qmi/instruments/dummy/noisy_sine_generator.py @@ -16,6 +16,7 @@ class NoisySineGenerator(QMI_Instrument): max_frequency: Maximum allowed frequency that can be set. max_amplitude: Maximum allowed amplitude that can be set. max_wait: Maximum wait time duration. + max_noise: Maximum noise level that can be set. By default the same as max amplitude. """ _rpc_constants = ["max_frequency", "max_amplitude", "max_wait"] @@ -39,7 +40,7 @@ def set_frequency(self, value: float) -> None: value: The frequency value (unitless). """ self._check_is_open() - valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value < self.max_frequency + valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value <= self.max_frequency if not valid: raise ValueError("Bad value for frequency: {!r}".format(value)) @@ -63,7 +64,7 @@ def set_amplitude(self, value: float) -> None: value: The new amplitude (unitless). """ self._check_is_open() - valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value < self.max_amplitude + valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value <= self.max_amplitude if not valid: raise ValueError("Bad value for amplitude: {!r}".format(value)) @@ -87,7 +88,7 @@ def set_noise(self, value: float) -> None: value: The new noise level (unitless). """ self._check_is_open() - valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value < self.max_noise + valid = isinstance(value, float) and math.isfinite(value) and 0.0 < value <= self.max_noise if not valid: raise ValueError("Bad value for noise: {!r}".format(value)) @@ -111,7 +112,7 @@ def wait(self, duration: float) -> None: duration: The wait duration in seconds. """ self._check_is_open() - if not 0.0 < duration < self.max_wait: + if not 0.0 < duration <= self.max_wait: raise ValueError("Bad value for wait duration: {!r}".format(duration)) time.sleep(duration) diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 6f0cfced..1a82c37c 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -384,10 +384,19 @@ def test_constants(self): proxy2 = self.c2.get_rpc_object_by_name("c1.tc1") # Check that constants are accessible via both proxies. - self.assertEqual(proxy1.CONSTANT_NUMBER(), 42) - self.assertEqual(proxy1.CONSTANT_STRING(), "testing") - self.assertEqual(proxy2.CONSTANT_NUMBER(), 42) - self.assertEqual(proxy2.CONSTANT_STRING(), "testing") + self.assertEqual(proxy1.CONSTANT_NUMBER, 42) + self.assertEqual(proxy1.CONSTANT_STRING, "testing") + self.assertEqual(proxy2.CONSTANT_NUMBER, 42) + self.assertEqual(proxy2.CONSTANT_STRING, "testing") + + # Check that constants are settable via both proxies. + proxy1.CONSTANT_NUMBER = 24 + self.assertEqual(proxy1.CONSTANT_NUMBER, 24) + self.assertEqual(proxy2.CONSTANT_NUMBER, 24) + + proxy2.CONSTANT_STRING = "changed" + self.assertEqual(proxy1.CONSTANT_STRING, "changed") + self.assertEqual(proxy2.CONSTANT_STRING, "changed") # Check that non-exported constants are not accessible. with self.assertRaises(AttributeError): diff --git a/tests/instruments/dummy/test_noisy_sine_generator.py b/tests/instruments/dummy/test_noisy_sine_generator.py index b10b8582..66d3209b 100644 --- a/tests/instruments/dummy/test_noisy_sine_generator.py +++ b/tests/instruments/dummy/test_noisy_sine_generator.py @@ -89,7 +89,7 @@ def test_max_frequency_change(self): with self.assertRaises(ValueError): self.nsg.set_frequency(invalid_freq) - self.nsg.max_frequency(new_max) + self.nsg.max_frequency = new_max self.nsg.set_frequency(invalid_freq) new_freq = self.nsg.get_frequency() @@ -122,7 +122,7 @@ def test_max_amplitude_change(self): with self.assertRaises(ValueError): self.nsg.set_amplitude(invalid_amp) - self.nsg.max_amplitude(new_max) + self.nsg.max_amplitude = new_max self.nsg.set_amplitude(invalid_amp) new_amp = self.nsg.get_amplitude() @@ -150,7 +150,7 @@ def test_noise_set_excepts(self): def test_max_noise_not_modifiable(self): """Test that 'max_noise' cannot be changed as it is not in _rpc_constants.""" with self.assertRaises(AttributeError): - self.nsg.max_noise(1.0) + self.nsg.max_noise = NSG.max_noise + 2.0 def test_wait_setting(self): """Test that wait waits.""" @@ -177,7 +177,7 @@ def test_max_wait_change(self): with self.assertRaises(ValueError): self.nsg.wait(invalid_wait) - self.nsg.max_wait(new_max) + self.nsg.max_wait = new_max self.nsg.wait(invalid_wait) def test_get_sample(self): From 7311a08c41f383e88afa32f7563d3acc1ad47392 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Wed, 20 May 2026 15:54:02 +0200 Subject: [PATCH 05/36] [changing-rpc-constants-on-proxy] Adding an explanation on tutorial about using RPC constants. --- documentation/sphinx/source/tutorial.rst | 82 ++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/documentation/sphinx/source/tutorial.rst b/documentation/sphinx/source/tutorial.rst index 37ecc3f8..a9293a53 100644 --- a/documentation/sphinx/source/tutorial.rst +++ b/documentation/sphinx/source/tutorial.rst @@ -78,13 +78,87 @@ We can look at the documentation of the Proxy instance: >>> help(nsg) This prints the docstring of the NoisySineGenerator class. -It also shows a listing of all RPC methods, signals and class constants of the proxy instance. - -As we can read in the help, our noisy sine generator ``nsg`` supports a bunch of methods, including the ``get_sample()`` method. +It also shows a listing of all RPC methods, signals and class constants of the proxy instance:: + Help on QMI_RpcProxy in module qmi.core.rpc: + + + Simulated instrument, useful for testing. + + Attributes: + max_frequency: Maximum allowed frequency that can be set. + max_amplitude: Maximum allowed amplitude that can be set. + max_wait: Maximum wait time duration. + max_noise: Maximum noise level that can be set. By default the same as max amplitude. + + + RPC methods: + - close() -> None + - get_amplitude() -> float + - get_frequency() -> float + - get_name() -> str + - get_noise() -> float + - get_sample() -> float + - get_signals() -> list[qmi.core.pubsub.SignalDescription] + - is_open() -> bool + - open() -> None + - set_amplitude(value: float) -> None + - set_frequency(value: float) -> None + - set_noise(value: float) -> None + - wait(duration: float) -> None + + QMI signals: + + RPC constants: + - max_frequency=1000000.0 + - max_amplitude=1000.0 + - max_wait=10.0 + + +Using RPC constants +^^^^^^^^^^^^^^^^^^^ + +From the docstring printed out you can see that there are four class attributes present: `max_frequency`, `max_amplitude`, `max_noise` and `max_wait`. But only three of these are listed under "RPC constants" and `max_noise` is not. +These attributes are used in the class limit different `set` and the `wait` functions to have a maximum possible settable value. Now, the maximum values defined as "RPC constants" are now actually modifiable, while the `max_noise` is not. +So, using the usual way of adjusting class variables, the three attributes can given new values, f.ex.: + +>>> nsg.max_amplitude + 1000.0 +>>> nsg.max_amplitude = 500.0 +>>> nsg.max_amplitude + 500.0 + +Note that to change the value, *the same value type must be used*. Trying to set the `max_amplitude` with an integer value (like `500`) will lead to an exception. +Also, trying to adjust `max_noise` will lead to an error: + +>>> nsg.max_noise = 20.0 + Traceback (most recent call last): + File "", line 1, in + nsg.max_noise = 20.0 + ^^^^^^^^^^^^^ + File "C:\Users\heevasti\source\github\QMI\qmi\core\rpc.py", line 834, in __setattr__ + raise AttributeError("Not allowed to set new attributes on a proxy class.") + AttributeError: Not allowed to set new attributes on a proxy class. + + +Calling RPC methods +^^^^^^^^^^^^^^^^^^^ + +As we can read in the help, our noisy sine generator ``nsg`` supports a bunch of methods, including ``get_sample()`` method. We can retrieve that method's docstring as well: >>> help(nsg.get_sample) + Help on method get_sample in module qmi.core.rpc: + + get_sample(*args, **kwargs) method of qmi.core.rpc.QMI_RpcProxy instance + rpc proxy for get_sample(self) -> float method of qmi.instruments.dummy.noisy_sine_generator.NoisySineGenerator instance. + + Get a 'sample' from the virtual instrument sine wave. + + Returns: + sample: A time-dependent sample value of sine wave with given frequency and amplitude, + and added Gaussian noise. + Now, let's give it a shot and see what happens: >>> nsg.get_sample() @@ -106,7 +180,7 @@ We can make a very basic graph of *nsg* samples as follows: ... print(" " * int(40.0 + 0.25 * nsg.get_sample()) + "*") ... time.sleep(0.01) -Feel free to experiment a bit with the other NoisySineGenerator methods, which you can read about by executing the ``help(nsg)``. +Feel free to experiment a bit with the NoisySineGenerator constants and other methods, of which you can read about by executing the ``help(nsg)``. Also, if you want, have a look at the source code of ``qmi.instruments.dummy.noisy_sine_generator``. This should convince you that implementing device drivers for QMI instruments is pretty straightforward. From e4841f3202f7a99bc6c8df3e4c278cc6c121545e Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 21 May 2026 09:53:56 +0200 Subject: [PATCH 06/36] [changing-rpc-constants-on-proxy] tutorial adjustments. --- CHANGELOG.md | 2 +- documentation/sphinx/source/tutorial.rst | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a201f59f..4181907d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects from their proxies. -- In `qmi.core.util` added functions for checking that two values are of same type or size and type if the type is an iterable (excluding string). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_constants`. +- In `qmi.core.util` added functions for checking that two values are of same type, or size and type if the type is an iterable (excluding size for strings). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_constants`. ## [0.53.0] - 2026-05-11 diff --git a/documentation/sphinx/source/tutorial.rst b/documentation/sphinx/source/tutorial.rst index a9293a53..21cbb2fa 100644 --- a/documentation/sphinx/source/tutorial.rst +++ b/documentation/sphinx/source/tutorial.rst @@ -128,14 +128,14 @@ So, using the usual way of adjusting class variables, the three attributes can g 500.0 Note that to change the value, *the same value type must be used*. Trying to set the `max_amplitude` with an integer value (like `500`) will lead to an exception. -Also, trying to adjust `max_noise` will lead to an error: +Also, trying to adjust `max_noise`, not included in RPC constants, will lead to an error: >>> nsg.max_noise = 20.0 Traceback (most recent call last): File "", line 1, in nsg.max_noise = 20.0 ^^^^^^^^^^^^^ - File "C:\Users\heevasti\source\github\QMI\qmi\core\rpc.py", line 834, in __setattr__ + File ".\qmi\core\rpc.py", line 834, in __setattr__ raise AttributeError("Not allowed to set new attributes on a proxy class.") AttributeError: Not allowed to set new attributes on a proxy class. @@ -166,7 +166,7 @@ Now, let's give it a shot and see what happens: Whoops, we got an error! This is because we didn't "open" the instrument first. Opening an instrument makes a connection to the instrument, which is potentially far away. Closing the instrument then closes the connection again. -Now, for a virtual instrument this is of course not necessary, but as it simulates a real instrument, we also simulate opening and closing. +Now, for a virtual instrument this is of course not necessary, but as it simulates a real instrument, we also simulate opening and closing. >>> nsg.open() >>> nsg.get_sample() @@ -190,7 +190,8 @@ When done, close the instrument and exit your Python interpreter: >>> nsg.close() >>> qmi.stop() -From now on, we will no longer tell you to execute ``qmi.stop()``, but don't forget to do it. +From now on, we will no longer tell you to execute ``qmi.stop()``, but don't forget to do it, or use a context manager. + Locking an instrument --------------------- @@ -240,11 +241,11 @@ The first proxy can interact with the instrument, but the second one cannot, bec 2021-11-30 14:50:55.786 | ERROR | qmi.core.rpc | nsg locked, method request without lock token is denied Traceback (most recent call last): File "", line 1, in - File "/Users/qutech/Development/qmi/qmi/qmi/core/rpc.py", line 566, in + File "./qmi/core/rpc.py", line 566, in blocking_rpc_method_call(self._context, self._rpc_object_address, method_name, self._lock_token, - File "/Users/qutech/Development/qmi/qmi/qmi/core/rpc.py", line 505, in blocking_rpc_method_call + File "./qmi/core/rpc.py", line 505, in blocking_rpc_method_call return future.wait(rpc_timeout) - File "/Users/qutech/Development/qmi/qmi/qmi/core/rpc.py", line 458, in wait + File "./qmi/core/rpc.py", line 458, in wait raise QMI_RuntimeException("The object is locked by another proxy") qmi.core.exceptions.QMI_RuntimeException: The object is locked by another proxy From 5cc23a128894c21216f072d6d4a63bc6d6495217 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 21 May 2026 10:25:55 +0200 Subject: [PATCH 07/36] [changing-rpc-constants-on-proxy] print also (parent) type of the RPC constant in help. --- documentation/sphinx/source/tutorial.rst | 6 +++--- qmi/core/rpc.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/documentation/sphinx/source/tutorial.rst b/documentation/sphinx/source/tutorial.rst index 21cbb2fa..0680ddd2 100644 --- a/documentation/sphinx/source/tutorial.rst +++ b/documentation/sphinx/source/tutorial.rst @@ -109,9 +109,9 @@ It also shows a listing of all RPC methods, signals and class constants of the p QMI signals: RPC constants: - - max_frequency=1000000.0 - - max_amplitude=1000.0 - - max_wait=10.0 + - max_frequency: float = 1000000.0 + - max_amplitude: float = 1000.0 + - max_wait: = 10.0 Using RPC constants diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index d2197d6c..9eee4420 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -1217,7 +1217,7 @@ def make_interface_descriptor( constant_value = getattr(rpc_object_class, constant_name) assert not inspect.isfunction(constant_value) constants.append(RpcConstantDescriptor(constant_name, constant_value)) - doc += f" - {constant_name}={constant_value}\n" + doc += f" - {constant_name}: {type(constant_value).__name__} = {constant_value}\n" # Create interface descriptor. return RpcInterfaceDescriptor( From 18f559c2c4575b231b5cc54f9a39e2473305f01d Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 21 May 2026 10:38:26 +0200 Subject: [PATCH 08/36] test fix --- tests/core/test_rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 1a82c37c..ba8f0cd5 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -155,7 +155,7 @@ def _get_rpc_methods_signals_constants(self, rpc_object_class, signal_declaratio assert hasattr(rpc_object_class, constant_name) constant_value = getattr(rpc_object_class, constant_name) assert not inspect.isfunction(constant_value) - doc += f" - {constant_name}={constant_value}\n" + doc += f" - {constant_name}: {type(constant_value).__name__} = {constant_value}\n" return doc From aee9d4fc0b9f0f9685c11400bf561f7bbedcf264 Mon Sep 17 00:00:00 2001 From: Badge Bot <> Date: Thu, 21 May 2026 08:45:49 +0000 Subject: [PATCH 09/36] Update badges --- .github/badges/coverage.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index d283ae09..efe04809 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -17,7 +17,7 @@ coverage - 91% - 91% + 92% + 92% From 2aacac4b5aa23bfd143c7ad718b3ff0209fb2382 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Fri, 22 May 2026 17:44:12 +0200 Subject: [PATCH 10/36] [changing-rpc-constants-on-proxy] Added a FAIR badge in README.md --- CHANGELOG.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4181907d..0a9126a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects from their proxies. - In `qmi.core.util` added functions for checking that two values are of same type, or size and type if the type is an iterable (excluding size for strings). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_constants`. +- Adding FAIR badge from https://fairsoftwarechecklist.net/v0.2/. ## [0.53.0] - 2026-05-11 diff --git a/README.md b/README.md index 6b3aab20..dfd0a0cf 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Documentation Status](https://readthedocs.org/projects/qmi/badge/?version=latest)](https://qmi.readthedocs.io/en/latest/?badge=latest) [![coverage](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/coverage.svg)](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/coverage.svg) [![tests](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/tests.svg)](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/tests.svg) +[![FAIR checklist badge](https://fairsoftwarechecklist.net/badge.svg)](https://fairsoftwarechecklist.net/v0.2?f=31&a=32113&i=22100&r=133) # Quantum Measurement Infrastructure From 88decccecc33c516663badbf581d39c77fe653c4 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Tue, 26 May 2026 09:19:32 +0200 Subject: [PATCH 11/36] Removing the FAIR checklist badge --- CHANGELOG.md | 1 - README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a9126a8..4181907d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects from their proxies. - In `qmi.core.util` added functions for checking that two values are of same type, or size and type if the type is an iterable (excluding size for strings). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_constants`. -- Adding FAIR badge from https://fairsoftwarechecklist.net/v0.2/. ## [0.53.0] - 2026-05-11 diff --git a/README.md b/README.md index dfd0a0cf..6b3aab20 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![Documentation Status](https://readthedocs.org/projects/qmi/badge/?version=latest)](https://qmi.readthedocs.io/en/latest/?badge=latest) [![coverage](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/coverage.svg)](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/coverage.svg) [![tests](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/tests.svg)](https://github.com/QuTech-Delft/QMI/blob/main/.github/badges/tests.svg) -[![FAIR checklist badge](https://fairsoftwarechecklist.net/badge.svg)](https://fairsoftwarechecklist.net/v0.2?f=31&a=32113&i=22100&r=133) # Quantum Measurement Infrastructure From 1d9102295911676a55b3b3c2b4a558652ebcdecb Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 4 Jun 2026 13:17:38 +0200 Subject: [PATCH 12/36] [changing-rpc-constants-on-proxy] Boy-scouting by adding missing rtscts definition in serial device transport string docstring in `create_transport`. --- qmi/core/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qmi/core/transport.py b/qmi/core/transport.py index 53e32b7d..d1d9f05c 100644 --- a/qmi/core/transport.py +++ b/qmi/core/transport.py @@ -1311,7 +1311,7 @@ def create_transport( - VXI-11 instrument: "vxi11:host" - UDP connection: "udp:host<:port>" - TCP connection: "tcp:host<:port><:connect_timeout=10>" - - Serial port: "serial:device<:baudrate=115200><:databits=8><:parity=N><:stopbits=1>" + - Serial port: "serial:device<:baudrate=115200><:databits=8><:parity=N><:stopbits=1><:rtscts=False>" - USBTMC device: "usbtmc:vendorid:productid:serialnr" - GPIB device: "gpib:primary_addr<:secondary_addr=None><:connect_timeout=30.0>" From c1acccb5b8fbb31544c3257dec312514caa36b51 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Tue, 9 Jun 2026 16:54:27 +0200 Subject: [PATCH 13/36] [changing-rpc-constants-on-proxy] Added extra checks on the possible _rpc_constants names so that no method names nor QMI signal names etc. can (accidentally) be named as _rpc_constants. But it makes several unittests to fail now. --- qmi/core/rpc.py | 44 ++++++++++++++++++++++++--- tests/core/test_rpc.py | 69 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index 9eee4420..f68179f7 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -1164,6 +1164,30 @@ def get_signals(self) -> list[SignalDescription]: return list(self._qmi_signals) # type: ignore +def _check_rpc_constants( + cls: Type[QMI_RpcObject], rpc_constant_names: list[str], protected_names: tuple[str, ...] +) -> None: + """Internal function to check that the RPC constant names do not include protected names nor QMI_Signal objects. + It can also not be an internal function nor property nor a dunder variable or method. + + The RPC constant names may include only class constants. + """ + cls_items = cls.__dict__ + for name in rpc_constant_names: + if ( + name in protected_names or + name not in cls.__dict__ or + name.startswith("__") or name.endswith("__") or + inspect.isroutine(cls_items[name]) or + isinstance(cls_items[name], (property, staticmethod, classmethod)) + ): + _logger.error( + f"RPC constant name {name} is invalid. Check that the name is not a " + + "protected name, QMI_Signal object, [internal] function, property nor a dunder variable name." + ) + raise QMI_UsageException(f"Invalid RPC constant name {name}.") + + def make_interface_descriptor( rpc_object_class: Type[QMI_RpcObject], signal_declaration_class: Type[QMI_RpcObject] | None = None ) -> RpcInterfaceDescriptor: @@ -1171,8 +1195,20 @@ def make_interface_descriptor( `QMI_RpcObject` subclass that can be accessed via RPC. Signal declarations are taken from the specified signal declaration class, which may be a different class than that from which the RPC methods are extracted. - """ + Parameters: + rpc_object_class: A QMI_RpcObject or a _ContextRpcObject. + signal_declaration_class: A QMI_RpcObject or any QMI_RpcObject derivate. + + Returns: + RpcInterfaceDescriptor: A descriptor about the RPC object and its interfaces. + + Raises: + QMI_UsageException: If trying to use any of the RPC lock method names in the RPC object. + QMI_UsageException: If trying to set an RPC constant that is already defined as a signal or + RPC method or lock method name. + """ + protected_method_names = ("lock", "unlock", "force_unlock", "is_locked") # Use the RPC object class as the class to extract signal declarations from if no signal declaration class was # provided by the caller. if signal_declaration_class is None: @@ -1184,7 +1220,7 @@ def make_interface_descriptor( # Extract RPC method declarations. methods = [] for name, member in inspect.getmembers(rpc_object_class, is_rpc_method): - if name in ("lock", "unlock", "force_unlock", "is_locked"): + if name in protected_method_names: raise QMI_UsageException(f"`{name}` is a protected method name") signature = str(inspect.signature(member)) @@ -1208,14 +1244,14 @@ def make_interface_descriptor( for base in inspect.getmro(rpc_object_class): if hasattr(base, "_rpc_constants"): constant_names.update(getattr(base, "_rpc_constants")) + # Check validity of RPC constant name[s] + _check_rpc_constants(signal_declaration_class, constant_names, protected_method_names) # Extract constant values. doc += '\nRPC constants:\n' constants = [] for constant_name in constant_names: - assert hasattr(rpc_object_class, constant_name) constant_value = getattr(rpc_object_class, constant_name) - assert not inspect.isfunction(constant_value) constants.append(RpcConstantDescriptor(constant_name, constant_value)) doc += f" - {constant_name}: {type(constant_value).__name__} = {constant_value}\n" diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index ba8f0cd5..51cd44df 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -3,6 +3,7 @@ import inspect import logging import math +from threading import Timer import time from typing import NamedTuple import unittest @@ -17,7 +18,7 @@ QMI_RpcObject, QMI_RpcTimeoutException, QMI_RpcFuture, QMI_RpcProxy, QMI_RpcNonBlockingProxy, rpc_method, is_rpc_method ) -from threading import Timer +from qmi.core.pubsub import QMI_Signal class MyRpcTestClass(QMI_RpcObject): @@ -29,7 +30,16 @@ class MyRpcTestClass(QMI_RpcObject): def __init__(self, context, name): super().__init__(context, name) - + self._variable_string = "Hi" + + @property + def variable_string(self) -> str: + return self._variable_string + + @staticmethod + def _call_me_maybe() -> bool: + return False + @rpc_method def remote_sqrt(self, x): """This is some_method.""" @@ -51,6 +61,7 @@ def my_lock_method(self, lock_token): class MyRpcSubClass(MyRpcTestClass): """An RPC sub class""" _rpc_constants = ["CONSTANT_STRING"] + mock_signal = QMI_Signal([None]) CONSTANT_STRING = "testing" @@ -152,9 +163,7 @@ def _get_rpc_methods_signals_constants(self, rpc_object_class, signal_declaratio # Extract constant values. doc += '\nRPC constants:\n' for constant_name in constant_names: - assert hasattr(rpc_object_class, constant_name) constant_value = getattr(rpc_object_class, constant_name) - assert not inspect.isfunction(constant_value) doc += f" - {constant_name}: {type(constant_value).__name__} = {constant_value}\n" return doc @@ -402,6 +411,58 @@ def test_constants(self): with self.assertRaises(AttributeError): proxy1.CONSTANT_FLOAT() + def test_invalid_constants(self): + """Test that RPC constants cannot have invalid names.""" + # Name cannot be a class attribute that is created at __init__ + MyRpcTestClass._rpc_constants = ["_variable_strings"] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcTestClass) + self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + + # Name cannot be a property + MyRpcTestClass._rpc_constants = ["variable_strings"] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcTestClass) + self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + + # Name cannot be a static method + MyRpcTestClass._rpc_constants = ["_call_me_maybe"] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcTestClass) + self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + + # Name cannot be a class method + MyRpcTestClass._rpc_constants = ["get_category"] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcTestClass) + self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + + # Name cannot be a function method + MyRpcTestClass._rpc_constants = ["release_rpc_object"] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcTestClass) + self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + + # Name cannot be a RPC method + MyRpcTestClass._rpc_constants = ["remote_sqrt"] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcTestClass) + self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + + # Name cannot be a protected name + for name in ("lock", "unlock", "force_unlock", "is_locked"): + MyRpcTestClass._rpc_constants = [name] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcTestClass) + self.assertIn(name, str(err.exception)) + + # Name cannot be a class QMI signal object name + MyRpcSubClass._rpc_constants = ["mock_signal"] + with self.assertRaises(QMI_UsageException) as err: + self.c1.make_rpc_object("tc1", MyRpcSubClass) + self.assertIn(MyRpcSubClass._rpc_constants[0], str(err.exception)) + + def test_call_to_disconnected(self): # Make instance of MyRpcSubClass in the first context. From 10c5a5c6c31dd84d1e503e6117b38fd8c3f91f60 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 11 Jun 2026 10:28:26 +0200 Subject: [PATCH 14/36] [changing-rpc-constants-on-proxy] Changed and specified the `_rpc_constants` to be a `set` type. Added more robust checking of the RPC constants so that giving any method, property, class name or QMI signal is not allowed. Boy-scouted a bug in `datastore.py`. --- CHANGELOG.md | 3 + qmi/core/rpc.py | 27 +++++--- qmi/data/datastore.py | 3 +- qmi/instruments/bristol/bristol_871a.py | 2 +- qmi/instruments/cobolt/laser_06_01.py | 2 +- qmi/instruments/dummy/noisy_sine_generator.py | 2 +- qmi/instruments/newport/ag_uc8.py | 4 +- qmi/instruments/newport/newport_843r.py | 2 +- qmi/instruments/pi/e873.py | 2 +- .../rohde_schwarz/rs_base_signal_gen.py | 2 +- qmi/instruments/thorlabs/k10crx.py | 2 +- qmi/instruments/thorlabs/kdc101.py | 2 +- qmi/instruments/thorlabs/mpc320.py | 4 +- qmi/instruments/timebase/dim3000.py | 4 +- qmi/instruments/yokogawa/dlm4038.py | 2 +- qmi/instruments/zurich_instruments/hdawg.py | 2 +- tests/core/test_rpc.py | 27 ++++---- tests/data/test_datastore.py | 67 +++++++++++++------ 18 files changed, 98 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4181907d..3a1e0f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects from their proxies. - In `qmi.core.util` added functions for checking that two values are of same type, or size and type if the type is an iterable (excluding size for strings). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_constants`. +### Changed +- Typed `_rpc_constants` to be a _set_ and changed all definitions to be _sets_. + ## [0.53.0] - 2026-05-11 ### Added diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index f68179f7..a75b0be9 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -1018,7 +1018,7 @@ class QMI_RpcObject(metaclass=_RpcObjectMetaClass): Subclasses of `QMI_RpcObject` may choose to export (a subset of) their constant class attributes to be accessible directly via the proxy. This is done by creating a class attribute `_rpc_constants` holding - a list of attribute names to be exported. + a set of attribute names to be exported. Each instance of `QMI_RpcObject` runs in a separate thread. It is not allowed to invoke methods of the `QMI_RpcObject` directly from outside the class. @@ -1032,6 +1032,8 @@ class QMI_RpcObject(metaclass=_RpcObjectMetaClass): into the QMI network and routed to subscribed receivers. """ + _rpc_constants: set[str] + @classmethod def get_category(cls) -> str | None: """Return the optional name of the category this object belongs to. @@ -1172,20 +1174,22 @@ def _check_rpc_constants( The RPC constant names may include only class constants. """ - cls_items = cls.__dict__ + # Constant name could be inherited, so we need to as well check if it is present in any possible parent class. + cls_items: dict[str, Any] = {} + [cls_items.update(parent.__dict__) for parent in inspect.getmro(cls)] for name in rpc_constant_names: if ( name in protected_names or - name not in cls.__dict__ or + not name in cls_items or name.startswith("__") or name.endswith("__") or inspect.isroutine(cls_items[name]) or isinstance(cls_items[name], (property, staticmethod, classmethod)) ): _logger.error( - f"RPC constant name {name} is invalid. Check that the name is not a " + + f"RPC constant name `{name}` is invalid. Check that the name is not a " + "protected name, QMI_Signal object, [internal] function, property nor a dunder variable name." ) - raise QMI_UsageException(f"Invalid RPC constant name {name}.") + raise QMI_UsageException(f"Invalid RPC constant name `{name}`.") def make_interface_descriptor( @@ -1204,9 +1208,9 @@ def make_interface_descriptor( RpcInterfaceDescriptor: A descriptor about the RPC object and its interfaces. Raises: - QMI_UsageException: If trying to use any of the RPC lock method names in the RPC object. + QMI_UsageException: If trying to use any of the protected RPC lock method names in the RPC object. QMI_UsageException: If trying to set an RPC constant that is already defined as a signal or - RPC method or lock method name. + [RPC] method or protected lock method name. """ protected_method_names = ("lock", "unlock", "force_unlock", "is_locked") # Use the RPC object class as the class to extract signal declarations from if no signal declaration class was @@ -1221,7 +1225,7 @@ def make_interface_descriptor( methods = [] for name, member in inspect.getmembers(rpc_object_class, is_rpc_method): if name in protected_method_names: - raise QMI_UsageException(f"`{name}` is a protected method name") + raise QMI_UsageException(f"`{name}` is a protected method name.") signature = str(inspect.signature(member)) docstring = member.__doc__ @@ -1239,13 +1243,14 @@ def make_interface_descriptor( signals.append(RpcSignalDescriptor(name, arg_types)) doc += f" - {name}{arg_types}\n" - # Extract constant declarations. + # Extract constant declarations, including possible base class[es]. constant_names = set() for base in inspect.getmro(rpc_object_class): if hasattr(base, "_rpc_constants"): - constant_names.update(getattr(base, "_rpc_constants")) + base_rpc_constants = getattr(base, "_rpc_constants") # Check validity of RPC constant name[s] - _check_rpc_constants(signal_declaration_class, constant_names, protected_method_names) + _check_rpc_constants(base, base_rpc_constants, protected_method_names) + constant_names.update(base_rpc_constants) # Extract constant values. doc += '\nRPC constants:\n' diff --git a/qmi/data/datastore.py b/qmi/data/datastore.py index bc964ab0..d20034c6 100644 --- a/qmi/data/datastore.py +++ b/qmi/data/datastore.py @@ -327,7 +327,8 @@ def add_dataset_to_file( raise QMI_UsageException(f"Data file already has an attribute named {ds.name}") if isinstance(hdf5_file, h5netcdf.File): - hdf5_file.dimensions[ds.column_label] = None + for column_label in ds.column_label: + hdf5_file.dimensions[column_label] = None keys = list(dict(hdf5_file.attrs).keys()) ds_count = 0 diff --git a/qmi/instruments/bristol/bristol_871a.py b/qmi/instruments/bristol/bristol_871a.py index fcd480c9..006f6a9b 100644 --- a/qmi/instruments/bristol/bristol_871a.py +++ b/qmi/instruments/bristol/bristol_871a.py @@ -148,7 +148,7 @@ class Bristol_871A(QMI_Instrument): STATUS_GOOD: Status value of a wavelength measurement without any issues. """ - _rpc_constants = ["RESPONSE_TIMEOUT", "STATUS_MASK", "STATUS_GOOD"] + _rpc_constants = {"RESPONSE_TIMEOUT", "STATUS_MASK", "STATUS_GOOD"} # Condition codes returned by get_condition(), by bit index. CONDITION_BITS = { diff --git a/qmi/instruments/cobolt/laser_06_01.py b/qmi/instruments/cobolt/laser_06_01.py index 0e18801f..28835094 100644 --- a/qmi/instruments/cobolt/laser_06_01.py +++ b/qmi/instruments/cobolt/laser_06_01.py @@ -16,7 +16,7 @@ class Cobolt_Laser_06_01(QMI_Instrument): """Instrument driver for the Cobolt 06-01 series diode laser.""" - _rpc_constants = ["RESPONSE_TIMEOUT"] + _rpc_constants = {"RESPONSE_TIMEOUT"} # Fault codes returned by get_operating_fault(). FAULT_CODES = { diff --git a/qmi/instruments/dummy/noisy_sine_generator.py b/qmi/instruments/dummy/noisy_sine_generator.py index 1f4989c9..273f6cef 100644 --- a/qmi/instruments/dummy/noisy_sine_generator.py +++ b/qmi/instruments/dummy/noisy_sine_generator.py @@ -19,7 +19,7 @@ class NoisySineGenerator(QMI_Instrument): max_noise: Maximum noise level that can be set. By default the same as max amplitude. """ - _rpc_constants = ["max_frequency", "max_amplitude", "max_wait"] + _rpc_constants = {"max_frequency", "max_amplitude", "max_wait"} max_frequency: float = 1e6 max_amplitude: float = 1e3 diff --git a/qmi/instruments/newport/ag_uc8.py b/qmi/instruments/newport/ag_uc8.py index 8a94f8db..0ea2373b 100644 --- a/qmi/instruments/newport/ag_uc8.py +++ b/qmi/instruments/newport/ag_uc8.py @@ -29,9 +29,9 @@ class AxisStatus(enum.IntEnum): class Newport_AG_UC8(QMI_Instrument): """Instrument driver for the Newport AG-UC8 Piezo Stepper Controller.""" - _rpc_constants = [ + _rpc_constants = { "RESPONSE_TIMEOUT", "SLOW_RESPONSE_TIMEOUT", "COMMAND_DELAY", "CHANNEL_SWITCH_DELAY", "RESET_DELAY" - ] + } # Meaning of error codes returned by the device. ERROR_CODES = { diff --git a/qmi/instruments/newport/newport_843r.py b/qmi/instruments/newport/newport_843r.py index 974323d7..4205a174 100644 --- a/qmi/instruments/newport/newport_843r.py +++ b/qmi/instruments/newport/newport_843r.py @@ -29,7 +29,7 @@ class Newport_843R(QMI_Instrument): """Instrument driver for the Newport 843-R optical power meter.""" - _rpc_constants = ["COMMAND_DELAY"] + _rpc_constants = {"COMMAND_DELAY"} # Meaning of sensor type codes returned by the instrument. SENSOR_TYPES = { diff --git a/qmi/instruments/pi/e873.py b/qmi/instruments/pi/e873.py index f9f7a87e..2df67ec2 100644 --- a/qmi/instruments/pi/e873.py +++ b/qmi/instruments/pi/e873.py @@ -58,7 +58,7 @@ class ReferenceSignalMode(enum.Enum): class PI_E873(QMI_Instrument): """Instrument driver for the PhysikInstrumente E-873 Servo Controller.""" - _rpc_constants = ["RESPONSE_TIMEOUT"] + _rpc_constants = {"RESPONSE_TIMEOUT"} # Error codes returned by instrument via ERR? query. ERROR_CODES = GCS_CONTROLLER_ERROR_CODES diff --git a/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py b/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py index aab0113c..2308bd63 100644 --- a/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py +++ b/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py @@ -19,7 +19,7 @@ class RohdeSchwarz_Base(QMI_Instrument): """Base class for the instrument driver for the Rohde&Schwarz Signal Generators.""" - _rpc_constants = ["DEFAULT_RESPONSE_TIMEOUT"] + _rpc_constants = {"DEFAULT_RESPONSE_TIMEOUT"} # Default response timeout in seconds. DEFAULT_RESPONSE_TIMEOUT = 5.0 diff --git a/qmi/instruments/thorlabs/k10crx.py b/qmi/instruments/thorlabs/k10crx.py index c2e62891..c19d75e5 100644 --- a/qmi/instruments/thorlabs/k10crx.py +++ b/qmi/instruments/thorlabs/k10crx.py @@ -46,7 +46,7 @@ class Thorlabs_K10CRxBase(QMI_Instrument): The value is based on K10CR1 as no data for K10CR2 found. MAX_ACCELERATION: Maximum acceleration in degrees/second^2. """ - _rpc_constants = ["RESPONSE_TIMEOUT", "MAX_VELOCITY", "MAX_ACCELERATION"] + _rpc_constants = {"RESPONSE_TIMEOUT", "MAX_VELOCITY", "MAX_ACCELERATION"} RESPONSE_TIMEOUT = 1.0 MAX_VELOCITY = 0 diff --git a/qmi/instruments/thorlabs/kdc101.py b/qmi/instruments/thorlabs/kdc101.py index 9127ca20..8d7408c7 100644 --- a/qmi/instruments/thorlabs/kdc101.py +++ b/qmi/instruments/thorlabs/kdc101.py @@ -38,7 +38,7 @@ class Thorlabs_Kdc101(QMI_Instrument): An adaptation of the driver could be made in the future to also allow the use of the linear translation and rotation stages, and goniometers. """ - _rpc_constants = ["RESPONSE_TIMEOUT"] + _rpc_constants = {"RESPONSE_TIMEOUT"} RESPONSE_TIMEOUT = 1.0 diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 2e683eab..5a72f538 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -71,13 +71,13 @@ class Thorlabs_Mpc320(QMI_Instrument): """ Driver for a Thorlabs MPC320 motorised fibre polarisation controller. """ - _rpc_constants = [ + _rpc_constants = { "DEFAULT_RESPONSE_TIMEOUT", "MIN_POSITION_DEGREES", "MAX_POSITION_DEGREES", "MIN_VELOCITY_PERC", "MAX_VELOCITY_PERC", - ] + } DEFAULT_RESPONSE_TIMEOUT = 0.5 diff --git a/qmi/instruments/timebase/dim3000.py b/qmi/instruments/timebase/dim3000.py index acd45ade..de8f02df 100644 --- a/qmi/instruments/timebase/dim3000.py +++ b/qmi/instruments/timebase/dim3000.py @@ -190,14 +190,14 @@ def __post_init__(self): class TimeBase_DIM3000(QMI_Instrument): """QMI Instrument driver for the TimeBase DIM3000 AOM driver.""" - _rpc_constants = [ + _rpc_constants = { "FREQ_RANGE", "TIME_RANGE", "PULSE_FREQ_RANGE", "DUTY_CYCLE_RANGE", "AM_OFFSET_RANGE", "MINIMUM_EXEC_DELAY_S", - ] + } # Public class constants FREQ_RANGE = (10, 400_000_000) diff --git a/qmi/instruments/yokogawa/dlm4038.py b/qmi/instruments/yokogawa/dlm4038.py index 74bdf882..41fb6334 100644 --- a/qmi/instruments/yokogawa/dlm4038.py +++ b/qmi/instruments/yokogawa/dlm4038.py @@ -44,7 +44,7 @@ class Yokogawa_DLM4038(QMI_Instrument): Arguments: CHANNELS: The number of signal channels in the device. """ - _rpc_constants = ["CHANNELS"] + _rpc_constants = {"CHANNELS"} CHANNELS = 8 def __init__( diff --git a/qmi/instruments/zurich_instruments/hdawg.py b/qmi/instruments/zurich_instruments/hdawg.py index f67a3f51..95c6762f 100644 --- a/qmi/instruments/zurich_instruments/hdawg.py +++ b/qmi/instruments/zurich_instruments/hdawg.py @@ -118,7 +118,7 @@ class ZurichInstruments_HDAWG(QMI_Instrument): TRIGGER_LEVEL_RANGE: The range of possible settable trigger levels. """ - _rpc_constants = ["COMPILE_TIMEOUT", "UPLOAD_TIMEOUT", "POLL_PERIOD", "NUM_AWGS", "NUM_CHANNELS"] + _rpc_constants = {"COMPILE_TIMEOUT", "UPLOAD_TIMEOUT", "POLL_PERIOD", "NUM_AWGS", "NUM_CHANNELS"} COMPILE_TIMEOUT = 30 UPLOAD_TIMEOUT = 30 POLL_PERIOD = 1.0 diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 51cd44df..1f54c839 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -23,7 +23,7 @@ class MyRpcTestClass(QMI_RpcObject): """An RPC test class""" - _rpc_constants = ["CONSTANT_NUMBER"] + _rpc_constants = {"CONSTANT_NUMBER"} CONSTANT_NUMBER = 42 CONSTANT_FLOAT = 3.1415 @@ -60,8 +60,8 @@ def my_lock_method(self, lock_token): class MyRpcSubClass(MyRpcTestClass): """An RPC sub class""" - _rpc_constants = ["CONSTANT_STRING"] - mock_signal = QMI_Signal([None]) + _rpc_constants = {"CONSTANT_STRING"} + mock_signal = QMI_Signal([float]) CONSTANT_STRING = "testing" @@ -206,6 +206,10 @@ def tearDown(self): logging.getLogger("qmi.core.rpc").setLevel(logging.NOTSET) logging.getLogger("qmi.core.messaging").setLevel(logging.NOTSET) + + # Reset the correct constants. + MyRpcTestClass._rpc_constants = {"CONSTANT_NUMBER"} + MyRpcSubClass._rpc_constants = {"CONSTANT_STRING"} def test_blocking_rpc(self): """Test for blocking RPC calls.""" @@ -414,55 +418,54 @@ def test_constants(self): def test_invalid_constants(self): """Test that RPC constants cannot have invalid names.""" # Name cannot be a class attribute that is created at __init__ - MyRpcTestClass._rpc_constants = ["_variable_strings"] + MyRpcTestClass._rpc_constants = {"_variable_strings"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) # Name cannot be a property - MyRpcTestClass._rpc_constants = ["variable_strings"] + MyRpcTestClass._rpc_constants = {"variable_strings"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) # Name cannot be a static method - MyRpcTestClass._rpc_constants = ["_call_me_maybe"] + MyRpcTestClass._rpc_constants = {"_call_me_maybe"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) # Name cannot be a class method - MyRpcTestClass._rpc_constants = ["get_category"] + MyRpcTestClass._rpc_constants = {"get_category"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) # Name cannot be a function method - MyRpcTestClass._rpc_constants = ["release_rpc_object"] + MyRpcTestClass._rpc_constants = {"release_rpc_object"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) # Name cannot be a RPC method - MyRpcTestClass._rpc_constants = ["remote_sqrt"] + MyRpcTestClass._rpc_constants = {"remote_sqrt"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) # Name cannot be a protected name for name in ("lock", "unlock", "force_unlock", "is_locked"): - MyRpcTestClass._rpc_constants = [name] + MyRpcTestClass._rpc_constants = {name} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(name, str(err.exception)) # Name cannot be a class QMI signal object name - MyRpcSubClass._rpc_constants = ["mock_signal"] + MyRpcSubClass._rpc_constants = {"mock_signal"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcSubClass) self.assertIn(MyRpcSubClass._rpc_constants[0], str(err.exception)) - def test_call_to_disconnected(self): # Make instance of MyRpcSubClass in the first context. diff --git a/tests/data/test_datastore.py b/tests/data/test_datastore.py index 9df18ae2..40d7244f 100644 --- a/tests/data/test_datastore.py +++ b/tests/data/test_datastore.py @@ -140,29 +140,10 @@ def test_06_read_dataset_in_hdf5(self): """See that we can read in a data set in HDF5 format.""" # Arrange expected_timestamp = 1776671773.6601706 - # expected_time_str = "2026-04-20T07:56:13" with unittest.mock.patch("qmi.data.dataset.time") as time_patch: time_patch.time = unittest.mock.Mock(return_value=expected_timestamp) expected_dataset = _create_dataset() - # ds_name = expected_dataset.name - # expected_attrs = expected_dataset.attrs.copy() - # expected_attrs.update( - # { - # f"{ds_name}_axis0_label": "X", - # f"{ds_name}_axis0_unit": "um", - # f"{ds_name}_axis1_label": "Z", - # f"{ds_name}_axis1_unit": "mm", - # f"{ds_name}_column0_label": "power", - # f"{ds_name}_column0_unit": "mW", - # f"{ds_name}_column1_label": "countrate", - # f"{ds_name}_column1_unit": "kHz", - # f"{ds_name}_column2_label": "temperature", - # f"{ds_name}_column2_unit": "K", - # f"{ds_name}_time_str": expected_time_str, - # f"{ds_name}_timestamp": expected_timestamp, - # } - # ) expected_file = os.path.join(os.getcwd(), expected_dataset.name + ".hdf5") try: self.datafolder.write_dataset(expected_dataset) @@ -436,6 +417,25 @@ def test_17_open_hdf5_file_in_write_mode(self): finally: os.remove(expected_file) + def test_17b_open_hdf5_file_in_write_mode_netcdf_backend(self): + """Open an existing hdf5 file in read/write mode.""" + # Arrange + name = "write_mode" + ".hdf5" + expected_file = os.path.join(os.getcwd(), name) + # Act and Assert + try: + with self.datafolder.make_hdf5file(name, backend="h5netcdf"): + self.assertTrue(os.path.isfile(expected_file)) + + with self.datafolder.open_hdf5file(name, write_mode=True, backend="h5netcdf") as hdf5_file: + hdf5_file.attrs["extra"] = "value" + + with self.datafolder.open_hdf5file(name, backend="h5netcdf") as hdf5_file: + self.assertEqual("value", hdf5_file.attrs["extra"]) + + finally: + os.remove(expected_file) + def test_18_read_dataset_with_path_name_raises_exception(self): """Reading a dataset rejects names that contain a path component.""" with self.assertRaises(ValueError): @@ -468,6 +468,33 @@ def test_19_add_dataset_to_hdf5_file(self): finally: os.remove(expected_file) + def test_19b_add_dataset_to_hdf5_file_netcdf_backend(self): + """Add a dataset and root attributes to an existing HDF5 file.""" + # Arrange + name = "combined" + ".hdf5" + expected_file = os.path.join(os.getcwd(), name) + dataset = _create_dataset() + root_attrs = {"operator": "QMI", "run": 7} + # Act and Assert + try: + with self.datafolder.make_hdf5file(name, backend="h5netcdf") as hdf5_file: + self.datafolder.add_dataset_to_file(hdf5_file, dataset, root_attrs=root_attrs) + self.assertIn(dataset.name, hdf5_file) + self.assertEqual(dataset.name, hdf5_file.attrs["QMI_Dataset_name_0"]) + self.assertEqual("QMI", hdf5_file.attrs["operator"]) + self.assertEqual(7, hdf5_file.attrs["run"]) + + with self.datafolder.open_hdf5file(name, backend="h5netcdf") as hdf5_file: + read_dataset = qmi.data.dataset.read_dataset_from_hdf5(hdf5_file[dataset.name]) + + self.assertEqual(dataset.name, read_dataset.name) + self.assertListEqual(dataset.axis_label, read_dataset.axis_label) + self.assertListEqual(dataset.column_label, read_dataset.column_label) + self.assertEqual(dataset.data.shape, read_dataset.data.shape) + + finally: + os.remove(expected_file) + def test_20_repr(self): """DataFolder repr includes its folder path.""" self.assertEqual("DataFolder({!r})".format(os.getcwd()), repr(self.datafolder)) @@ -628,7 +655,6 @@ def test_01_make_folder_only_name_as_input(self): finally: os.removedirs(expected_folder) - # os.removedirs(day_folder) def test_02_make_folder_with_timestamp_as_input(self): """Make a new datastore folder in the base folder and that a DataFolder instance is returned.""" @@ -763,7 +789,6 @@ def test_08_make_folder_only_name_as_input(self): finally: os.removedirs(expected_folder) - # os.removedirs(day_folder) def test_09_get_folder_with_name_date_and_time_as_inputs(self): """Get a datastore folder with the given input values.""" From 288fcc3bad232bb2b52bfeba81823740424fd085 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 11 Jun 2026 11:24:14 +0200 Subject: [PATCH 15/36] [changing-rpc-constants-on-proxy] Changed the "RPC Constants" to "RPC Properties" everywhere where applicable. --- CHANGELOG.md | 6 +- documentation/sphinx/source/tutorial.rst | 14 +- qmi/core/rpc.py | 208 +++++++++--------- qmi/instruments/adwin/adwin.py | 2 + qmi/instruments/bristol/bristol_871a.py | 2 +- qmi/instruments/cobolt/laser_06_01.py | 2 +- qmi/instruments/dummy/noisy_sine_generator.py | 2 +- qmi/instruments/newport/ag_uc8.py | 2 +- qmi/instruments/newport/newport_843r.py | 2 +- qmi/instruments/pi/e873.py | 2 +- .../rohde_schwarz/rs_base_signal_gen.py | 2 +- qmi/instruments/thorlabs/k10crx.py | 2 +- qmi/instruments/thorlabs/kdc101.py | 2 +- qmi/instruments/thorlabs/mpc320.py | 2 +- qmi/instruments/timebase/dim3000.py | 4 +- qmi/instruments/wavelength/tclab.py | 1 + qmi/instruments/yokogawa/dlm4038.py | 2 +- qmi/instruments/zurich_instruments/hdawg.py | 2 +- tests/core/test_rpc.py | 111 +++++----- .../dummy/test_noisy_sine_generator.py | 10 +- 20 files changed, 192 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1e0f14..4ba43df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.54.0-beta.0] - Unreleased ### Added -- Functions in `qmi.core.rpc` that enable RPC messages to adjust class constants marked as `_rpc_constants` in QMI RPC objects from their proxies. -- In `qmi.core.util` added functions for checking that two values are of same type, or size and type if the type is an iterable (excluding size for strings). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_constants`. +- Functions in `qmi.core.rpc` that enable RPC messages to adjust class properties marked as `_rpc_properties` in QMI RPC objects from their proxies. +- In `qmi.core.util` added functions for checking that two values are of same type, or size and type if the type is an iterable (excluding size for strings). This is used for checking that we set only same type of values (and of same size for iterables) as modifiable `_rpc_properties`. ### Changed -- Typed `_rpc_constants` to be a _set_ and changed all definitions to be _sets_. +- Typed `_rpc_properties` to be a _set_ and changed all definitions to be _sets_. ## [0.53.0] - 2026-05-11 diff --git a/documentation/sphinx/source/tutorial.rst b/documentation/sphinx/source/tutorial.rst index 0680ddd2..a7c511b8 100644 --- a/documentation/sphinx/source/tutorial.rst +++ b/documentation/sphinx/source/tutorial.rst @@ -78,7 +78,7 @@ We can look at the documentation of the Proxy instance: >>> help(nsg) This prints the docstring of the NoisySineGenerator class. -It also shows a listing of all RPC methods, signals and class constants of the proxy instance:: +It also shows a listing of all RPC methods, signals and class properties of the proxy instance:: Help on QMI_RpcProxy in module qmi.core.rpc: @@ -108,17 +108,17 @@ It also shows a listing of all RPC methods, signals and class constants of the p QMI signals: - RPC constants: + RPC Properties: - max_frequency: float = 1000000.0 - max_amplitude: float = 1000.0 - max_wait: = 10.0 -Using RPC constants +Using RPC Properties ^^^^^^^^^^^^^^^^^^^ -From the docstring printed out you can see that there are four class attributes present: `max_frequency`, `max_amplitude`, `max_noise` and `max_wait`. But only three of these are listed under "RPC constants" and `max_noise` is not. -These attributes are used in the class limit different `set` and the `wait` functions to have a maximum possible settable value. Now, the maximum values defined as "RPC constants" are now actually modifiable, while the `max_noise` is not. +From the docstring printed out you can see that there are four class attributes present: `max_frequency`, `max_amplitude`, `max_noise` and `max_wait`. But only three of these are listed under "RPC Properties" and `max_noise` is not. +These attributes are used in the class limit different `set` and the `wait` functions to have a maximum possible settable value. Now, the maximum values defined as "RPC Properties" are now actually modifiable, while the `max_noise` is not. So, using the usual way of adjusting class variables, the three attributes can given new values, f.ex.: >>> nsg.max_amplitude @@ -128,7 +128,7 @@ So, using the usual way of adjusting class variables, the three attributes can g 500.0 Note that to change the value, *the same value type must be used*. Trying to set the `max_amplitude` with an integer value (like `500`) will lead to an exception. -Also, trying to adjust `max_noise`, not included in RPC constants, will lead to an error: +Also, trying to adjust `max_noise`, not included in RPC Properties, will lead to an error: >>> nsg.max_noise = 20.0 Traceback (most recent call last): @@ -180,7 +180,7 @@ We can make a very basic graph of *nsg* samples as follows: ... print(" " * int(40.0 + 0.25 * nsg.get_sample()) + "*") ... time.sleep(0.01) -Feel free to experiment a bit with the NoisySineGenerator constants and other methods, of which you can read about by executing the ``help(nsg)``. +Feel free to experiment a bit with the NoisySineGenerator properties and other methods, of which you can read about by executing the ``help(nsg)``. Also, if you want, have a look at the source code of ``qmi.instruments.dummy.noisy_sine_generator``. This should convince you that implementing device drivers for QMI instruments is pretty straightforward. diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index a75b0be9..e7103d75 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -170,12 +170,12 @@ def square(self, x): _T = TypeVar("_T") -class RpcConstantDescriptor(NamedTuple): - """Description of an RPC constant. +class RpcPropertyDescriptor(NamedTuple): + """Description of an RPC Property. Attributes: - name: Name of the constant. - value: Value of the constant. + name: Name of the property. + value: Value of the property. """ name: str value: Any @@ -216,7 +216,7 @@ class RpcInterfaceDescriptor(NamedTuple): rpc_class_module: Name of the module in which the RPC object class was defined. rpc_class_name: Name of the RPC object class. rpc_class_docstring: Docstring of the RPC object class. - constants: A list of constant descriptors for the RPC constants declared + properties: A list of property descriptors for the RPC Properties declared by the RPC object class. methods: A list of method descriptors for the RPC methods declared by the RPC object class. @@ -226,7 +226,7 @@ class RpcInterfaceDescriptor(NamedTuple): rpc_class_module: str rpc_class_name: str rpc_class_docstring: str | None - constants: list[RpcConstantDescriptor] + properties: list[RpcPropertyDescriptor] methods: list[RpcMethodDescriptor] signals: list[RpcSignalDescriptor] @@ -329,36 +329,36 @@ def __init__( self.lock_token = lock_token -class QMI_ConstantRpcRequestMessage(QMI_RequestMessage): - """Message sent by an RPC client to get or change a RPC constant value of a remote object. +class QMI_PropertyRpcRequestMessage(QMI_RequestMessage): + """Message sent by an RPC client to get or change a RPC Property value of a remote object. - See `QMI_ConstantRpcReplyMessage` for how to interpret the reply to a request. + See `QMI_PropertyRpcReplyMessage` for how to interpret the reply to a request. Attributes: - constant_name: The constant name. - constant_value: The new constant value. - set_constant_value: True to set the constant, False to get the current value. + property_name: The property name. + property_value: The new property value. + set_property_value: True to set the property, False to get the current value. lock_token: The unique token to use for the lock. """ - __slots__ = ("constant_name", "constant_value", "set_constant_value", "lock_token") + __slots__ = ("property_name", "property_value", "set_property_value", "lock_token") def __init__( self, source_address: QMI_MessageHandlerAddress, destination_address: QMI_MessageHandlerAddress, - constant_name: str, - constant_value: Any, - set_constant_value: bool, + property_name: str, + property_value: Any, + set_property_value: bool, lock_token: QMI_LockTokenDescriptor | None = None ) -> None: super().__init__(source_address, destination_address) - self.constant_name = constant_name - self.constant_value = constant_value - self.set_constant_value = set_constant_value + self.property_name = property_name + self.property_value = property_value + self.set_property_value = set_property_value self.lock_token = lock_token -class QMI_ConstantRpcReplyMessage(QMI_ReplyMessage): +class QMI_PropertyRpcReplyMessage(QMI_ReplyMessage): """Message sent back to an RPC client with the result of the action. Attributes: @@ -490,25 +490,25 @@ def send_method_rpc_request_message( except QMI_MessageDeliveryException as exc: self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, exc) - def send_constant_rpc_request_message( + def send_property_rpc_request_message( self, - rpc_constant_name: str, - rpc_constant_value: Any, - set_constant_value: bool + rpc_property_name: str, + rpc_property_value: Any, + set_property_value: bool ) -> None: - """Send a request message to the RPC object to get or modify the specified constant. + """Send a request message to the RPC object to get or modify the specified property. Parameters: - rpc_constant_name: Name of the constant to modify. - rpc_constant_value: The new constant value. - set_constant_value: True to modify the constant, False to get its current value. + rpc_property_name: Name of the property to modify. + rpc_property_value: The new property value. + set_property_value: True to modify the property, False to get its current value. """ - request = QMI_ConstantRpcRequestMessage( + request = QMI_PropertyRpcRequestMessage( self.address, self.rpc_object_address, - rpc_constant_name, - rpc_constant_value, - set_constant_value, + rpc_property_name, + rpc_property_value, + set_property_value, self.lock_token ) @@ -532,8 +532,8 @@ def handle_message(self, message: QMI_Message) -> None: if isinstance(message, QMI_MethodRpcReplyMessage): # Received result from RPC method call. self._set_result(message.state, message.result) - elif isinstance(message, QMI_ConstantRpcReplyMessage): - # Received result from RPC constant call. + elif isinstance(message, QMI_PropertyRpcReplyMessage): + # Received result from RPC Property call. self._set_result(message.state, message.result) elif isinstance(message, QMI_LockRpcReplyMessage): # Response to lock request message. @@ -628,18 +628,18 @@ def wait(self, timeout: float | None = None) -> Any: self._context.unregister_message_handler(self) -def rpc_constant_call( +def rpc_property_call( context: "qmi.core.context.QMI_Context", rpc_object_address: QMI_MessageHandlerAddress, - constant_name: str, + property_name: str, rpc_lock_token: QMI_LockTokenDescriptor | None, - constant_value: Any = None, + property_value: Any = None, *, - set_constant_value: bool = False + set_property_value: bool = False ) -> Any: - """Helper function that performs a call to get or change a specific constant of the target RPC object.""" + """Helper function that performs a call to get or change a specific property of the target RPC object.""" future = QMI_RpcFuture(context, rpc_object_address, rpc_lock_token) - future.send_constant_rpc_request_message(constant_name, constant_value, set_constant_value) + future.send_property_rpc_request_message(property_name, property_value, set_property_value) return future.wait() @@ -746,8 +746,8 @@ def __init__(self, context: "qmi.core.context.QMI_Context", descriptor: RpcObjec self._rpc_object_address = descriptor.address self._rpc_class_fqn = ".".join((descriptor.interface.rpc_class_module, descriptor.interface.rpc_class_name)) self._lock_token: QMI_LockTokenDescriptor | None = None - self._rpc_constant_names = frozenset( - constant_descriptor.name for constant_descriptor in descriptor.interface.constants + self._rpc_property_names = frozenset( + property_descriptor.name for property_descriptor in descriptor.interface.properties ) def make_rpc_method_forward_function(method_name: str): @@ -796,9 +796,9 @@ def make_rpc_method_forward_function(method_name: str): self.rpc_nonblocking = QMI_RpcNonBlockingProxy(context, descriptor) def __getattribute__(self, name: str) -> Any: - rpc_constant_names = object.__getattribute__(self, "_rpc_constant_names") - if name in rpc_constant_names: - return rpc_constant_call( + rpc_property_names = object.__getattribute__(self, "_rpc_property_names") + if name in rpc_property_names: + return rpc_property_call( object.__getattribute__(self, "_context"), object.__getattribute__(self, "_rpc_object_address"), name, @@ -814,15 +814,15 @@ def __setattr__(self, name: str, value: Any) -> None: initialized = False if initialized: - rpc_constant_names = object.__getattribute__(self, "_rpc_constant_names") - if name in rpc_constant_names: - rpc_constant_call( + rpc_property_names = object.__getattribute__(self, "_rpc_property_names") + if name in rpc_property_names: + rpc_property_call( object.__getattribute__(self, "_context"), object.__getattribute__(self, "_rpc_object_address"), name, object.__getattribute__(self, "_lock_token"), value, - set_constant_value=True + set_property_value=True ) return @@ -1016,8 +1016,8 @@ class QMI_RpcObject(metaclass=_RpcObjectMetaClass): (a subset of) their methods to mark them as callable via RPC. Subclasses of `QMI_RpcObject` may choose to export (a subset of) - their constant class attributes to be accessible directly via the proxy. - This is done by creating a class attribute `_rpc_constants` holding + their property class attributes to be accessible directly via the proxy. + This is done by creating a class attribute `_rpc_properties` holding a set of attribute names to be exported. Each instance of `QMI_RpcObject` runs in a separate thread. It is not allowed @@ -1032,7 +1032,7 @@ class QMI_RpcObject(metaclass=_RpcObjectMetaClass): into the QMI network and routed to subscribed receivers. """ - _rpc_constants: set[str] + _rpc_properties: set[str] @classmethod def get_category(cls) -> str | None: @@ -1166,18 +1166,18 @@ def get_signals(self) -> list[SignalDescription]: return list(self._qmi_signals) # type: ignore -def _check_rpc_constants( - cls: Type[QMI_RpcObject], rpc_constant_names: list[str], protected_names: tuple[str, ...] +def _check_rpc_properties( + cls: Type[QMI_RpcObject], rpc_property_names: list[str], protected_names: tuple[str, ...] ) -> None: - """Internal function to check that the RPC constant names do not include protected names nor QMI_Signal objects. + """Internal function to check that the RPC property names do not include protected names nor QMI_Signal objects. It can also not be an internal function nor property nor a dunder variable or method. - The RPC constant names may include only class constants. + The RPC property names may include only class properties. """ - # Constant name could be inherited, so we need to as well check if it is present in any possible parent class. + # Property name could be inherited, so we need to as well check if it is present in any possible parent class. cls_items: dict[str, Any] = {} [cls_items.update(parent.__dict__) for parent in inspect.getmro(cls)] - for name in rpc_constant_names: + for name in rpc_property_names: if ( name in protected_names or not name in cls_items or @@ -1186,10 +1186,10 @@ def _check_rpc_constants( isinstance(cls_items[name], (property, staticmethod, classmethod)) ): _logger.error( - f"RPC constant name `{name}` is invalid. Check that the name is not a " + + f"RPC Property name `{name}` is invalid. Check that the name is not a " + "protected name, QMI_Signal object, [internal] function, property nor a dunder variable name." ) - raise QMI_UsageException(f"Invalid RPC constant name `{name}`.") + raise QMI_UsageException(f"Invalid RPC Property name `{name}`.") def make_interface_descriptor( @@ -1209,7 +1209,7 @@ def make_interface_descriptor( Raises: QMI_UsageException: If trying to use any of the protected RPC lock method names in the RPC object. - QMI_UsageException: If trying to set an RPC constant that is already defined as a signal or + QMI_UsageException: If trying to set an RPC Property that is already defined as a signal or [RPC] method or protected lock method name. """ protected_method_names = ("lock", "unlock", "force_unlock", "is_locked") @@ -1243,26 +1243,26 @@ def make_interface_descriptor( signals.append(RpcSignalDescriptor(name, arg_types)) doc += f" - {name}{arg_types}\n" - # Extract constant declarations, including possible base class[es]. - constant_names = set() + # Extract property declarations, including possible base class[es]. + property_names = set() for base in inspect.getmro(rpc_object_class): - if hasattr(base, "_rpc_constants"): - base_rpc_constants = getattr(base, "_rpc_constants") - # Check validity of RPC constant name[s] - _check_rpc_constants(base, base_rpc_constants, protected_method_names) - constant_names.update(base_rpc_constants) - - # Extract constant values. - doc += '\nRPC constants:\n' - constants = [] - for constant_name in constant_names: - constant_value = getattr(rpc_object_class, constant_name) - constants.append(RpcConstantDescriptor(constant_name, constant_value)) - doc += f" - {constant_name}: {type(constant_value).__name__} = {constant_value}\n" + if hasattr(base, "_rpc_properties"): + base_rpc_properties = getattr(base, "_rpc_properties") + # Check validity of RPC Property name[s] + _check_rpc_properties(base, base_rpc_properties, protected_method_names) + property_names.update(base_rpc_properties) + + # Extract property values. + doc += '\nRPC Properties:\n' + properties = [] + for property_name in property_names: + property_value = getattr(rpc_object_class, property_name) + properties.append(RpcPropertyDescriptor(property_name, property_value)) + doc += f" - {property_name}: {type(property_value).__name__} = {property_value}\n" # Create interface descriptor. return RpcInterfaceDescriptor( - rpc_object_class.__module__, rpc_object_class.__name__, doc, constants, methods, signals + rpc_object_class.__module__, rpc_object_class.__name__, doc, properties, methods, signals ) @@ -1359,30 +1359,30 @@ def _handle_lock_rpc_request(self, request: QMI_LockRpcRequestMessage) -> QMI_Lo ) return reply - def _handle_constant_rpc_request(self, request: QMI_ConstantRpcRequestMessage) -> QMI_ConstantRpcReplyMessage: - """Handle RPC constant request.""" + def _handle_property_rpc_request(self, request: QMI_PropertyRpcRequestMessage) -> QMI_PropertyRpcReplyMessage: + """Handle RPC Property request.""" assert self._rpc_object is not None - # RPC constant call - need to check if the caller may invoke the RPC constant: allowed if the object is not + # RPC Property call - need to check if the caller may invoke the RPC Property: allowed if the object is not # locked (token is None) or if the provided lock token matches the locking token. if self._locking_token is None or self._locking_token == request.lock_token: - # Modify the constant; this can raise an exception or return a result. + # Modify the property; this can raise an exception or return a result. try: - constant = self._check_and_modify_constant(request) + property = self._check_and_modify_property(request) result_type = QMI_RpcFutureState.RESULT_IS_VALUE - result = constant + result = property except BaseException as exception: - _logger.debug("RPC constant modify failed", exc_info=True) + _logger.debug("RPC Property modify failed", exc_info=True) result_type = QMI_RpcFutureState.RESULT_IS_EXCEPTION result = exception else: - _logger.error("%s locked, constant request without lock token is denied.", self._rpc_object._name) + _logger.error("%s locked, property request without lock token is denied.", self._rpc_object._name) result_type = QMI_RpcFutureState.OBJECT_IS_LOCKED result = None - reply = QMI_ConstantRpcReplyMessage( + reply = QMI_PropertyRpcReplyMessage( source_address=request.destination_address, destination_address=request.source_address, request_id=request.request_id, @@ -1391,27 +1391,27 @@ def _handle_constant_rpc_request(self, request: QMI_ConstantRpcRequestMessage) - ) return reply - def _check_and_modify_constant(self, request: QMI_ConstantRpcRequestMessage) -> Any: - """Check if the object has the constant requested and is RPC-able; if so, return it.""" + def _check_and_modify_property(self, request: QMI_PropertyRpcRequestMessage) -> Any: + """Check if the object has the property requested and is RPC-able; if so, return it.""" assert self._rpc_object is not None - # Check that the constant was marked as RPC constant. - if not hasattr(self._rpc_object, request.constant_name): + # Check that the property was marked as RPC Property. + if not hasattr(self._rpc_object, request.property_name): raise QMI_UnknownRpcException( f"Object {request.destination_address.object_id} of type {type(self._rpc_object).__name__}" +\ - f" does not have constant {request.constant_name}." + f" does not have property {request.property_name}." ) - constant = getattr(self._rpc_object, request.constant_name) - if not request.set_constant_value: - return constant + property = getattr(self._rpc_object, request.property_name) + if not request.set_property_value: + return property - if not check_value_structures_equal(constant, request.constant_value): - raise QMI_UnknownRpcException("New RPC constant value is of different type or size than original.") + if not check_value_structures_equal(property, request.property_value): + raise QMI_UnknownRpcException("New RPC Property value is of different type or size than original.") - setattr(self._rpc_object, request.constant_name, request.constant_value) + setattr(self._rpc_object, request.property_name, request.property_value) - return getattr(self._rpc_object, request.constant_name) + return getattr(self._rpc_object, request.property_name) def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QMI_MethodRpcReplyMessage: """Handle RPC method request.""" @@ -1476,7 +1476,7 @@ def _reject_remaining_requests(self) -> None: # Sanity check (this has already been checked by the RpcObjectManager). assert isinstance(request, ( - QMI_MethodRpcRequestMessage, QMI_ConstantRpcRequestMessage, QMI_LockRpcRequestMessage + QMI_MethodRpcRequestMessage, QMI_PropertyRpcRequestMessage, QMI_LockRpcRequestMessage ) ) @@ -1563,11 +1563,11 @@ def run(self) -> None: request = self._fifo.popleft() # Process request. - reply: QMI_MethodRpcReplyMessage | QMI_ConstantRpcReplyMessage | QMI_LockRpcReplyMessage | None + reply: QMI_MethodRpcReplyMessage | QMI_PropertyRpcReplyMessage | QMI_LockRpcReplyMessage | None if isinstance(request, QMI_MethodRpcRequestMessage): reply = self._handle_method_rpc_request(request) - elif isinstance(request, QMI_ConstantRpcRequestMessage): - reply = self._handle_constant_rpc_request(request) + elif isinstance(request, QMI_PropertyRpcRequestMessage): + reply = self._handle_property_rpc_request(request) elif isinstance(request, QMI_LockRpcRequestMessage): reply = self._handle_lock_rpc_request(request) else: @@ -1603,7 +1603,7 @@ def run(self) -> None: def push_rpc_request( self, - rpc_request: QMI_MethodRpcRequestMessage | QMI_ConstantRpcRequestMessage | QMI_LockRpcRequestMessage | None + rpc_request: QMI_MethodRpcRequestMessage | QMI_PropertyRpcRequestMessage | QMI_LockRpcRequestMessage | None ) -> None: """Push an RPC request into the request queue and notify the thread.""" with self._cv: @@ -1688,7 +1688,7 @@ def handle_message(self, message: QMI_Message) -> None: """Called when a QMI message is delivered for our RPC object.""" if not isinstance(message, ( - QMI_MethodRpcRequestMessage, QMI_ConstantRpcRequestMessage, QMI_LockRpcRequestMessage + QMI_MethodRpcRequestMessage, QMI_PropertyRpcRequestMessage, QMI_LockRpcRequestMessage ) ): _logger.error( diff --git a/qmi/instruments/adwin/adwin.py b/qmi/instruments/adwin/adwin.py index f3e1b7c9..edc8ca37 100644 --- a/qmi/instruments/adwin/adwin.py +++ b/qmi/instruments/adwin/adwin.py @@ -57,6 +57,8 @@ class Adwin_Base(QMI_Instrument): parameters and data that are dependent on processor type. The 'MAX_m' values here are valid for at least the T11, T12 and T12.1 processor types. The maximum number of slots instead is dependent on the enclosure type of the ADwin. """ + _rpc_parameters = {"PROCESS_STOP_TIMEOUT"} + MAX_PAR: int = 80 MAX_DATA: int = 200 MAX_PROCESS_NO: int = 10 diff --git a/qmi/instruments/bristol/bristol_871a.py b/qmi/instruments/bristol/bristol_871a.py index 006f6a9b..52f326c3 100644 --- a/qmi/instruments/bristol/bristol_871a.py +++ b/qmi/instruments/bristol/bristol_871a.py @@ -148,7 +148,7 @@ class Bristol_871A(QMI_Instrument): STATUS_GOOD: Status value of a wavelength measurement without any issues. """ - _rpc_constants = {"RESPONSE_TIMEOUT", "STATUS_MASK", "STATUS_GOOD"} + _rpc_properties = {"RESPONSE_TIMEOUT", "STATUS_MASK", "STATUS_GOOD"} # Condition codes returned by get_condition(), by bit index. CONDITION_BITS = { diff --git a/qmi/instruments/cobolt/laser_06_01.py b/qmi/instruments/cobolt/laser_06_01.py index 28835094..889e1818 100644 --- a/qmi/instruments/cobolt/laser_06_01.py +++ b/qmi/instruments/cobolt/laser_06_01.py @@ -16,7 +16,7 @@ class Cobolt_Laser_06_01(QMI_Instrument): """Instrument driver for the Cobolt 06-01 series diode laser.""" - _rpc_constants = {"RESPONSE_TIMEOUT"} + _rpc_properties = {"RESPONSE_TIMEOUT"} # Fault codes returned by get_operating_fault(). FAULT_CODES = { diff --git a/qmi/instruments/dummy/noisy_sine_generator.py b/qmi/instruments/dummy/noisy_sine_generator.py index 273f6cef..6110179b 100644 --- a/qmi/instruments/dummy/noisy_sine_generator.py +++ b/qmi/instruments/dummy/noisy_sine_generator.py @@ -19,7 +19,7 @@ class NoisySineGenerator(QMI_Instrument): max_noise: Maximum noise level that can be set. By default the same as max amplitude. """ - _rpc_constants = {"max_frequency", "max_amplitude", "max_wait"} + _rpc_properties = {"max_frequency", "max_amplitude", "max_wait"} max_frequency: float = 1e6 max_amplitude: float = 1e3 diff --git a/qmi/instruments/newport/ag_uc8.py b/qmi/instruments/newport/ag_uc8.py index 0ea2373b..9ea6c31b 100644 --- a/qmi/instruments/newport/ag_uc8.py +++ b/qmi/instruments/newport/ag_uc8.py @@ -29,7 +29,7 @@ class AxisStatus(enum.IntEnum): class Newport_AG_UC8(QMI_Instrument): """Instrument driver for the Newport AG-UC8 Piezo Stepper Controller.""" - _rpc_constants = { + _rpc_properties = { "RESPONSE_TIMEOUT", "SLOW_RESPONSE_TIMEOUT", "COMMAND_DELAY", "CHANNEL_SWITCH_DELAY", "RESET_DELAY" } diff --git a/qmi/instruments/newport/newport_843r.py b/qmi/instruments/newport/newport_843r.py index 4205a174..12c99072 100644 --- a/qmi/instruments/newport/newport_843r.py +++ b/qmi/instruments/newport/newport_843r.py @@ -29,7 +29,7 @@ class Newport_843R(QMI_Instrument): """Instrument driver for the Newport 843-R optical power meter.""" - _rpc_constants = {"COMMAND_DELAY"} + _rpc_properties = {"COMMAND_DELAY"} # Meaning of sensor type codes returned by the instrument. SENSOR_TYPES = { diff --git a/qmi/instruments/pi/e873.py b/qmi/instruments/pi/e873.py index 2df67ec2..010d191a 100644 --- a/qmi/instruments/pi/e873.py +++ b/qmi/instruments/pi/e873.py @@ -58,7 +58,7 @@ class ReferenceSignalMode(enum.Enum): class PI_E873(QMI_Instrument): """Instrument driver for the PhysikInstrumente E-873 Servo Controller.""" - _rpc_constants = {"RESPONSE_TIMEOUT"} + _rpc_properties = {"RESPONSE_TIMEOUT"} # Error codes returned by instrument via ERR? query. ERROR_CODES = GCS_CONTROLLER_ERROR_CODES diff --git a/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py b/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py index 2308bd63..90f70192 100644 --- a/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py +++ b/qmi/instruments/rohde_schwarz/rs_base_signal_gen.py @@ -19,7 +19,7 @@ class RohdeSchwarz_Base(QMI_Instrument): """Base class for the instrument driver for the Rohde&Schwarz Signal Generators.""" - _rpc_constants = {"DEFAULT_RESPONSE_TIMEOUT"} + _rpc_properties = {"DEFAULT_RESPONSE_TIMEOUT"} # Default response timeout in seconds. DEFAULT_RESPONSE_TIMEOUT = 5.0 diff --git a/qmi/instruments/thorlabs/k10crx.py b/qmi/instruments/thorlabs/k10crx.py index c19d75e5..ab676cb7 100644 --- a/qmi/instruments/thorlabs/k10crx.py +++ b/qmi/instruments/thorlabs/k10crx.py @@ -46,7 +46,7 @@ class Thorlabs_K10CRxBase(QMI_Instrument): The value is based on K10CR1 as no data for K10CR2 found. MAX_ACCELERATION: Maximum acceleration in degrees/second^2. """ - _rpc_constants = {"RESPONSE_TIMEOUT", "MAX_VELOCITY", "MAX_ACCELERATION"} + _rpc_properties = {"RESPONSE_TIMEOUT", "MAX_VELOCITY", "MAX_ACCELERATION"} RESPONSE_TIMEOUT = 1.0 MAX_VELOCITY = 0 diff --git a/qmi/instruments/thorlabs/kdc101.py b/qmi/instruments/thorlabs/kdc101.py index 8d7408c7..5d85448e 100644 --- a/qmi/instruments/thorlabs/kdc101.py +++ b/qmi/instruments/thorlabs/kdc101.py @@ -38,7 +38,7 @@ class Thorlabs_Kdc101(QMI_Instrument): An adaptation of the driver could be made in the future to also allow the use of the linear translation and rotation stages, and goniometers. """ - _rpc_constants = {"RESPONSE_TIMEOUT"} + _rpc_properties = {"RESPONSE_TIMEOUT"} RESPONSE_TIMEOUT = 1.0 diff --git a/qmi/instruments/thorlabs/mpc320.py b/qmi/instruments/thorlabs/mpc320.py index 5a72f538..58b77a8c 100644 --- a/qmi/instruments/thorlabs/mpc320.py +++ b/qmi/instruments/thorlabs/mpc320.py @@ -71,7 +71,7 @@ class Thorlabs_Mpc320(QMI_Instrument): """ Driver for a Thorlabs MPC320 motorised fibre polarisation controller. """ - _rpc_constants = { + _rpc_properties = { "DEFAULT_RESPONSE_TIMEOUT", "MIN_POSITION_DEGREES", "MAX_POSITION_DEGREES", diff --git a/qmi/instruments/timebase/dim3000.py b/qmi/instruments/timebase/dim3000.py index de8f02df..4131a05e 100644 --- a/qmi/instruments/timebase/dim3000.py +++ b/qmi/instruments/timebase/dim3000.py @@ -190,7 +190,7 @@ def __post_init__(self): class TimeBase_DIM3000(QMI_Instrument): """QMI Instrument driver for the TimeBase DIM3000 AOM driver.""" - _rpc_constants = { + _rpc_properties = { "FREQ_RANGE", "TIME_RANGE", "PULSE_FREQ_RANGE", @@ -199,7 +199,7 @@ class TimeBase_DIM3000(QMI_Instrument): "MINIMUM_EXEC_DELAY_S", } - # Public class constants + # Public class properties FREQ_RANGE = (10, 400_000_000) TIME_RANGE = (4, 262000) PULSE_FREQ_RANGE = (20, 1000) diff --git a/qmi/instruments/wavelength/tclab.py b/qmi/instruments/wavelength/tclab.py index 98421cff..01776a77 100644 --- a/qmi/instruments/wavelength/tclab.py +++ b/qmi/instruments/wavelength/tclab.py @@ -49,6 +49,7 @@ class Wavelength_TC_Lab(QMI_Instrument): Configuring temperature sensors and sensor parameters is not supported. The auxiliary temperature sensor is not supported. """ + _rpc_properties = {"COMMAND_RESPONSE_TIMEOUT", "OPEN_MAX_RETRY"} USB_VENDOR_ID = 0x1a45 USB_PRODUCT_ID = 0x3101 diff --git a/qmi/instruments/yokogawa/dlm4038.py b/qmi/instruments/yokogawa/dlm4038.py index 41fb6334..123dd053 100644 --- a/qmi/instruments/yokogawa/dlm4038.py +++ b/qmi/instruments/yokogawa/dlm4038.py @@ -44,7 +44,7 @@ class Yokogawa_DLM4038(QMI_Instrument): Arguments: CHANNELS: The number of signal channels in the device. """ - _rpc_constants = {"CHANNELS"} + _rpc_properties = {"CHANNELS"} CHANNELS = 8 def __init__( diff --git a/qmi/instruments/zurich_instruments/hdawg.py b/qmi/instruments/zurich_instruments/hdawg.py index 95c6762f..df9703ac 100644 --- a/qmi/instruments/zurich_instruments/hdawg.py +++ b/qmi/instruments/zurich_instruments/hdawg.py @@ -118,7 +118,7 @@ class ZurichInstruments_HDAWG(QMI_Instrument): TRIGGER_LEVEL_RANGE: The range of possible settable trigger levels. """ - _rpc_constants = {"COMPILE_TIMEOUT", "UPLOAD_TIMEOUT", "POLL_PERIOD", "NUM_AWGS", "NUM_CHANNELS"} + _rpc_properties = {"COMPILE_TIMEOUT", "UPLOAD_TIMEOUT", "POLL_PERIOD", "NUM_AWGS", "NUM_CHANNELS"} COMPILE_TIMEOUT = 30 UPLOAD_TIMEOUT = 30 POLL_PERIOD = 1.0 diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 1f54c839..fe684859 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -23,9 +23,10 @@ class MyRpcTestClass(QMI_RpcObject): """An RPC test class""" - _rpc_constants = {"CONSTANT_NUMBER"} + _rpc_properties = {"PROPERTY_NUMBER"} - CONSTANT_NUMBER = 42 + PROPERTY_NUMBER = 42 + # Class constant, not a modifiable property CONSTANT_FLOAT = 3.1415 def __init__(self, context, name): @@ -60,10 +61,10 @@ def my_lock_method(self, lock_token): class MyRpcSubClass(MyRpcTestClass): """An RPC sub class""" - _rpc_constants = {"CONSTANT_STRING"} + _rpc_properties = {"PROPERTY_STRING"} mock_signal = QMI_Signal([float]) - CONSTANT_STRING = "testing" + PROPERTY_STRING = "testing" @rpc_method def remote_log(self, x): @@ -75,7 +76,7 @@ class ProxyInterface(NamedTuple): rpc_class_module: str = "SomeClass" rpc_class_name: str = "ClassyName" rpc_class_docstring: str = """This is Some Classy docstring.""" - constants: list = [] + properties: list = [] methods: list = [] signals: list = [] @@ -133,7 +134,7 @@ def test_nonblocking_context_manager_excepts(self): class TestRPC(unittest.TestCase): - def _get_rpc_methods_signals_constants(self, rpc_object_class, signal_declaration_class): + def _get_rpc_methods_signals_properties(self, rpc_object_class, signal_declaration_class): # Get the class docstring for updating it with info doc = "" doc += '\n\nRPC methods:\n' @@ -154,17 +155,17 @@ def _get_rpc_methods_signals_constants(self, rpc_object_class, signal_declaratio arg_types = "(" + ", ".join(arg_type.__name__ for arg_type in signal_description.arg_types) + ")" doc += f" - {name}{arg_types}\n" - # Extract constant declarations. - constant_names = set() + # Extract property declarations. + property_names = set() for base in inspect.getmro(rpc_object_class): - if hasattr(base, "_rpc_constants"): - constant_names.update(getattr(base, "_rpc_constants")) + if hasattr(base, "_rpc_properties"): + property_names.update(getattr(base, "_rpc_properties")) - # Extract constant values. - doc += '\nRPC constants:\n' - for constant_name in constant_names: - constant_value = getattr(rpc_object_class, constant_name) - doc += f" - {constant_name}: {type(constant_value).__name__} = {constant_value}\n" + # Extract property values. + doc += '\nRPC Properties:\n' + for property_name in property_names: + property_value = getattr(rpc_object_class, property_name) + doc += f" - {property_name}: {type(property_value).__name__} = {property_value}\n" return doc @@ -206,16 +207,16 @@ def tearDown(self): logging.getLogger("qmi.core.rpc").setLevel(logging.NOTSET) logging.getLogger("qmi.core.messaging").setLevel(logging.NOTSET) - - # Reset the correct constants. - MyRpcTestClass._rpc_constants = {"CONSTANT_NUMBER"} - MyRpcSubClass._rpc_constants = {"CONSTANT_STRING"} + + # Reset the correct properties. + MyRpcTestClass._rpc_properties = {"PROPERTY_NUMBER"} + MyRpcSubClass._rpc_properties = {"PROPERTY_STRING"} def test_blocking_rpc(self): """Test for blocking RPC calls.""" - # Get class documentation and update it with RPC methods, signals and constants listing + # Get class documentation and update it with RPC methods, signals and properties listing orig_doc = MyRpcTestClass.__doc__ - orig_doc += self._get_rpc_methods_signals_constants(MyRpcTestClass, MyRpcTestClass) + orig_doc += self._get_rpc_methods_signals_properties(MyRpcTestClass, MyRpcTestClass) # Instantiate the class, as a thing to be serviced from context c1. # This gives us a proxy to the instance. proxy1 = self.c1.make_rpc_object("tc1", MyRpcTestClass) @@ -259,9 +260,9 @@ def test_blocking_rpc_timeout(self): def test_nonblocking_rpc(self): """Test for non-blocking RPC calls.""" - # Get class documentation and update it with RPC methods, signals and constants listing + # Get class documentation and update it with RPC methods, signals and properties listing orig_doc = MyRpcTestClass.__doc__ - orig_doc += self._get_rpc_methods_signals_constants(MyRpcTestClass, MyRpcTestClass) + orig_doc += self._get_rpc_methods_signals_properties(MyRpcTestClass, MyRpcTestClass) # Instantiate the class, as a thing to be serviced from context c1. # This gives us a proxy to the instance. proxy1 = self.c1.make_rpc_object("tc1", MyRpcTestClass) @@ -368,9 +369,9 @@ def test_force_unlock(self): def test_subclass(self): - # Get class documentation and update it with RPC methods, signals and constants listing + # Get class documentation and update it with RPC methods, signals and properties listing orig_doc = MyRpcSubClass.__doc__ - orig_doc += self._get_rpc_methods_signals_constants(MyRpcSubClass, MyRpcSubClass) + orig_doc += self._get_rpc_methods_signals_properties(MyRpcSubClass, MyRpcSubClass) # Make instance of MyRpcSubClass in the first context. proxy1 = self.c1.make_rpc_object("tc1", MyRpcSubClass) @@ -388,7 +389,7 @@ def test_subclass(self): self.assertEqual(orig_doc, proxy1.__doc__) self.assertEqual(proxy1.__doc__, proxy2.__doc__) - def test_constants(self): + def test_properties(self): # Make instance of MyRpcSubClass in the first context. proxy1 = self.c1.make_rpc_object("tc1", MyRpcSubClass) @@ -396,75 +397,75 @@ def test_constants(self): # Make a proxy via the second context. proxy2 = self.c2.get_rpc_object_by_name("c1.tc1") - # Check that constants are accessible via both proxies. - self.assertEqual(proxy1.CONSTANT_NUMBER, 42) - self.assertEqual(proxy1.CONSTANT_STRING, "testing") - self.assertEqual(proxy2.CONSTANT_NUMBER, 42) - self.assertEqual(proxy2.CONSTANT_STRING, "testing") + # Check that properties are accessible via both proxies. + self.assertEqual(proxy1.PROPERTY_NUMBER, 42) + self.assertEqual(proxy1.PROPERTY_STRING, "testing") + self.assertEqual(proxy2.PROPERTY_NUMBER, 42) + self.assertEqual(proxy2.PROPERTY_STRING, "testing") - # Check that constants are settable via both proxies. - proxy1.CONSTANT_NUMBER = 24 - self.assertEqual(proxy1.CONSTANT_NUMBER, 24) - self.assertEqual(proxy2.CONSTANT_NUMBER, 24) + # Check that properties are settable via both proxies. + proxy1.PROPERTY_NUMBER = 24 + self.assertEqual(proxy1.PROPERTY_NUMBER, 24) + self.assertEqual(proxy2.PROPERTY_NUMBER, 24) - proxy2.CONSTANT_STRING = "changed" - self.assertEqual(proxy1.CONSTANT_STRING, "changed") - self.assertEqual(proxy2.CONSTANT_STRING, "changed") + proxy2.PROPERTY_STRING = "changed" + self.assertEqual(proxy1.PROPERTY_STRING, "changed") + self.assertEqual(proxy2.PROPERTY_STRING, "changed") # Check that non-exported constants are not accessible. with self.assertRaises(AttributeError): proxy1.CONSTANT_FLOAT() - def test_invalid_constants(self): - """Test that RPC constants cannot have invalid names.""" + def test_invalid_properties(self): + """Test that RPC Properties cannot have invalid names.""" # Name cannot be a class attribute that is created at __init__ - MyRpcTestClass._rpc_constants = {"_variable_strings"} + MyRpcTestClass._rpc_properties = {"_variable_strings"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) - self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception)) # Name cannot be a property - MyRpcTestClass._rpc_constants = {"variable_strings"} + MyRpcTestClass._rpc_properties = {"variable_strings"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) - self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception)) # Name cannot be a static method - MyRpcTestClass._rpc_constants = {"_call_me_maybe"} + MyRpcTestClass._rpc_properties = {"_call_me_maybe"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) - self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception)) # Name cannot be a class method - MyRpcTestClass._rpc_constants = {"get_category"} + MyRpcTestClass._rpc_properties = {"get_category"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) - self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception)) # Name cannot be a function method - MyRpcTestClass._rpc_constants = {"release_rpc_object"} + MyRpcTestClass._rpc_properties = {"release_rpc_object"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) - self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception)) # Name cannot be a RPC method - MyRpcTestClass._rpc_constants = {"remote_sqrt"} + MyRpcTestClass._rpc_properties = {"remote_sqrt"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) - self.assertIn(MyRpcTestClass._rpc_constants[0], str(err.exception)) + self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception)) # Name cannot be a protected name for name in ("lock", "unlock", "force_unlock", "is_locked"): - MyRpcTestClass._rpc_constants = {name} + MyRpcTestClass._rpc_properties = {name} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcTestClass) self.assertIn(name, str(err.exception)) # Name cannot be a class QMI signal object name - MyRpcSubClass._rpc_constants = {"mock_signal"} + MyRpcSubClass._rpc_properties = {"mock_signal"} with self.assertRaises(QMI_UsageException) as err: self.c1.make_rpc_object("tc1", MyRpcSubClass) - self.assertIn(MyRpcSubClass._rpc_constants[0], str(err.exception)) + self.assertIn(MyRpcSubClass._rpc_properties[0], str(err.exception)) def test_call_to_disconnected(self): diff --git a/tests/instruments/dummy/test_noisy_sine_generator.py b/tests/instruments/dummy/test_noisy_sine_generator.py index 66d3209b..5528af64 100644 --- a/tests/instruments/dummy/test_noisy_sine_generator.py +++ b/tests/instruments/dummy/test_noisy_sine_generator.py @@ -50,7 +50,7 @@ def test_function_call_excepts_if_not_open(self): class TestNsgFunctions(unittest.TestCase): - """Tests for Dummy NSG methods and constants.""" + """Tests for Dummy NSG methods and properties.""" def setUp(self): qmi.start("dummy", None) @@ -82,7 +82,7 @@ def test_frequency_set_excepts(self): self.nsg.set_frequency(invalid_freq) def test_max_frequency_change(self): - """Test that _rpc_constants 'max_frequency' constant can be manipulated.""" + """Test that _rpc_properties 'max_frequency' property can be manipulated.""" invalid_freq = NSG.max_frequency + 1.0 new_max = NSG.max_frequency + 2.0 @@ -115,7 +115,7 @@ def test_amplitude_set_excepts(self): self.nsg.set_amplitude(invalid_amp) def test_max_amplitude_change(self): - """Test that _rpc_constants 'max_amplitude' constant can be manipulated.""" + """Test that _rpc_properties 'max_amplitude' property can be manipulated.""" invalid_amp = NSG.max_amplitude + 1.0 new_max = NSG.max_amplitude + 2.0 @@ -148,7 +148,7 @@ def test_noise_set_excepts(self): self.nsg.set_noise(invalid_noise) def test_max_noise_not_modifiable(self): - """Test that 'max_noise' cannot be changed as it is not in _rpc_constants.""" + """Test that 'max_noise' cannot be changed as it is not in _rpc_properties.""" with self.assertRaises(AttributeError): self.nsg.max_noise = NSG.max_noise + 2.0 @@ -170,7 +170,7 @@ def test_wait_excepts(self): self.nsg.wait(invalid_wait) def test_max_wait_change(self): - """Test that _rpc_constants 'max_wait' constant can be manipulated.""" + """Test that _rpc_properties 'max_wait' property can be manipulated.""" invalid_wait = NSG.max_wait + 0.1 new_max = NSG.max_wait + 0.2 From 7c1c9b02e3a537a962a351a57cd298294147c61b Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 11 Jun 2026 11:24:49 +0200 Subject: [PATCH 16/36] [changing-rpc-constants-on-proxy] fixed a mistake in adwin. --- qmi/instruments/adwin/adwin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qmi/instruments/adwin/adwin.py b/qmi/instruments/adwin/adwin.py index edc8ca37..4d3ccabd 100644 --- a/qmi/instruments/adwin/adwin.py +++ b/qmi/instruments/adwin/adwin.py @@ -57,7 +57,7 @@ class Adwin_Base(QMI_Instrument): parameters and data that are dependent on processor type. The 'MAX_m' values here are valid for at least the T11, T12 and T12.1 processor types. The maximum number of slots instead is dependent on the enclosure type of the ADwin. """ - _rpc_parameters = {"PROCESS_STOP_TIMEOUT"} + _rpc_properties = {"PROCESS_STOP_TIMEOUT"} MAX_PAR: int = 80 MAX_DATA: int = 200 From 023e9e61a21eeaad22286c7ab19a88f09ecc4ee7 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 11 Jun 2026 12:15:06 +0200 Subject: [PATCH 17/36] [changing-rpc-constants-on-proxy] Added a warning on use of `_rpc_constants` about it being deprecated. --- CHANGELOG.md | 3 +++ qmi/core/rpc.py | 22 ++++++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba43df0..8f1fd21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Typed `_rpc_properties` to be a _set_ and changed all definitions to be _sets_. +### Deprecated +- Set the `_rpc_constants` to be deprecated and to point to `_rpc_properties`. + ## [0.53.0] - 2026-05-11 ### Added diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index e7103d75..835bbc25 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -136,16 +136,16 @@ def square(self, x): ######### """ +from abc import ABCMeta +from collections import deque +from collections.abc import Callable +import enum import inspect import logging import threading import time -import enum -from abc import ABCMeta -from collections import deque -from collections.abc import Callable - from typing import Any, NamedTuple, Type, TypeVar, TYPE_CHECKING +import warnings from qmi.core.exceptions import ( QMI_RuntimeException, @@ -1241,11 +1241,21 @@ def make_interface_descriptor( name = signal_description.name arg_types = "(" + ", ".join(arg_type.__name__ for arg_type in signal_description.arg_types) + ")" signals.append(RpcSignalDescriptor(name, arg_types)) - doc += f" - {name}{arg_types}\n" + doc += f" - {name}{arg_types}\n" # Extract property declarations, including possible base class[es]. property_names = set() for base in inspect.getmro(rpc_object_class): + # Check for deprecated use of '_rpc_constants' + if hasattr(base, "_rpc_constants"): + warnings.warn( + "The use of '_rpc_constants' is deprecated and will be removed in a future release. " + + "Use '_rpc_properties' instead. Now declaring them as '_rpc_properties'." + ) + rpc_constants = getattr(base, "_rpc_constants") + _check_rpc_properties(base, rpc_constants, protected_method_names) + property_names.update(rpc_constants) + if hasattr(base, "_rpc_properties"): base_rpc_properties = getattr(base, "_rpc_properties") # Check validity of RPC Property name[s] From bef57fe5b57b9196e6a67d553190bc97639caef9 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Fri, 26 Jun 2026 08:07:34 +0200 Subject: [PATCH 18/36] [changing-rpc-constants] trying a mypy fix --- qmi/data/dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qmi/data/dataset.py b/qmi/data/dataset.py index fcf0f6ae..daaae600 100644 --- a/qmi/data/dataset.py +++ b/qmi/data/dataset.py @@ -792,7 +792,7 @@ def write_dataset_to_text(dataset: DataSet, fh: TextIO) -> None: if dataset.data.ndim == 1: rawdata = dataset.data.reshape(-1, 1) elif dataset.data.ndim > 2: - nrow = np.prod(dataset.data.shape[:-1]) + nrow = int(np.prod(dataset.data.shape[:-1])) rawdata = dataset.data.reshape((nrow, dataset.ncol)) else: rawdata = dataset.data From f93feb735d7d69a3198e943efc8583bcdeb64bc3 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 13:54:48 +0200 Subject: [PATCH 19/36] [214-changing-rpc-constants-on-proxy] Fix an error in the unittests. --- tests/core/test_rpc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 7f78b580..59603dbd 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -9,6 +9,7 @@ import unittest from unittest.mock import Mock, MagicMock +import qmi from qmi.core.config_defs import CfgQmi, CfgContext from qmi.core.context import QMI_Context from qmi.core.exceptions import ( From d17823cee4be71c926e5a8e8a074d310c6d4c8be Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 13:58:31 +0200 Subject: [PATCH 20/36] [214-changing-rpc-constants-on-proxy] Fix an error in the unittests. --- tests/core/test_rpc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 59603dbd..06dc3236 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -73,7 +73,7 @@ def remote_log(self, x): @rpc_method def foute_boel(self): - raise RuntimeError("U can't run this") + raise RuntimeError("U can't run this.") class ProxyInterface(NamedTuple): @@ -961,7 +961,6 @@ def test_exception_results_in_error_logging_in_Rpc_Future(self): # Make a proxy via the second context. proxy2 = self.c2.get_rpc_object_by_name("c1.tc1") with self.assertLogs(qmi.core.rpc._logger.name, level="ERROR") as log_mes: - qmi.core.rpc._logger.error("gjeos") with self.assertRaises(RuntimeError): # Start a remote RPC call from the second proxy that will except. proxy2.foute_boel() From cb4bc3a6fa6af577a4398dbf03354c53e7a1258a Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 15:54:01 +0200 Subject: [PATCH 21/36] [214-changing-rpc-constants-on-proxy] Trying to fox coverage issue by limiting coverage version in pyproject.toml to 7.13.5. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 24e5c7f9..fd6c12fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage", + "coverage==7.13.5", "mypy", "ruff", # For generating documentation From 21224728f4a925066045aee396166c7a7723e38a Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 16:05:38 +0200 Subject: [PATCH 22/36] [214-changing-rpc-constants-on-proxy] Trying to fox coverage issue by limiting coverage version in pyproject.toml to 7.2~ --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fd6c12fb..d43b31eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage==7.13.5", + "coverage=~7.12", "mypy", "ruff", # For generating documentation From c427a8c9f374a747e4b65b43c5c7b9ce89e84b81 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 16:10:46 +0200 Subject: [PATCH 23/36] [214-changing-rpc-constants-on-proxy] Trying to fox coverage issue by limiting coverage version in pyproject.toml to 7.2~ --- .github/workflows/reusable-ci-workflows.yml | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-ci-workflows.yml b/.github/workflows/reusable-ci-workflows.yml index c84a5f27..66ab2835 100644 --- a/.github/workflows/reusable-ci-workflows.yml +++ b/.github/workflows/reusable-ci-workflows.yml @@ -61,6 +61,7 @@ jobs: path: mypy-${{ inputs.python-version }}.log - name: Run coverage + timeout-minutes: 15 run: | coverage run --branch --source=$SOURCE_DIRS -m unittest discover --start-directory=tests --pattern="test_*.py" coverage report --show-missing --fail-under=$COVERAGE_MIN_PERC | tee coverage-${{ inputs.python-version }}.log diff --git a/pyproject.toml b/pyproject.toml index d43b31eb..39167e6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage=~7.12", + "coverage~=7.12", "mypy", "ruff", # For generating documentation From b8b58a330b86cd2700a10f871a20cf5368528974 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 16:26:26 +0200 Subject: [PATCH 24/36] [214-changing-rpc-constants-on-proxy] Trying to fox coverage issue by limiting coverage version in pyproject.toml to 7.14.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 39167e6b..c8b4c9c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage~=7.12", + "coverage==7.14.1", "mypy", "ruff", # For generating documentation From 4028fa512bffdbb9ab01c8c47587dabe3b203c2b Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 16:27:50 +0200 Subject: [PATCH 25/36] [214-changing-rpc-constants-on-proxy] Trying to fix coverage issue by removing .coveragerc file --- .coveragerc | 3 --- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index f6c5c9ec..00000000 --- a/.coveragerc +++ /dev/null @@ -1,3 +0,0 @@ -[run] -core = ctrace -branch = True diff --git a/pyproject.toml b/pyproject.toml index c8b4c9c6..24e5c7f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage==7.14.1", + "coverage", "mypy", "ruff", # For generating documentation From 1d0cbf1b977162ddd7736d3a597ebbfec1e37458 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 16:37:15 +0200 Subject: [PATCH 26/36] [214-changing-rpc-constants-on-proxy] Trying to fix coverage issue by setting to version 7.9.2. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 24e5c7f9..016ad8c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage", + "coverage==7.9.2", "mypy", "ruff", # For generating documentation From 69bfe10d8978afeb28d13fd01028985aaf96f868 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 16:50:52 +0200 Subject: [PATCH 27/36] [214-changing-rpc-constants-on-proxy] Removed previous coverage version limits and now setting Python versions to 3.13.13 and 3.14.5. Also placed back `.coveragerc` file --- .coveragerc | 3 +++ .github/workflows/pull-request-ci.yml | 2 +- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..6ae8b547 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +core = ctrace +branch = True \ No newline at end of file diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index b06de470..4285acc8 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -20,7 +20,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13.13", "3.14.5"] uses: ./.github/workflows/reusable-ci-workflows.yml with: diff --git a/pyproject.toml b/pyproject.toml index 016ad8c6..fd6c12fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage==7.9.2", + "coverage==7.13.5", "mypy", "ruff", # For generating documentation From 9ebb294b53c217eaf6799f21a1e1207445211e74 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 17:07:32 +0200 Subject: [PATCH 28/36] [214-changing-rpc-constants-on-proxy] Split coverage in multiple folders. setting back to use latest Python versions. --- .github/workflows/pull-request-ci.yml | 2 +- .github/workflows/reusable-ci-workflows.yml | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 4285acc8..b06de470 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -20,7 +20,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.11", "3.12", "3.13.13", "3.14.5"] + python-version: ["3.11", "3.12", "3.13", "3.14"] uses: ./.github/workflows/reusable-ci-workflows.yml with: diff --git a/.github/workflows/reusable-ci-workflows.yml b/.github/workflows/reusable-ci-workflows.yml index 66ab2835..d068faf3 100644 --- a/.github/workflows/reusable-ci-workflows.yml +++ b/.github/workflows/reusable-ci-workflows.yml @@ -63,7 +63,23 @@ jobs: - name: Run coverage timeout-minutes: 15 run: | - coverage run --branch --source=$SOURCE_DIRS -m unittest discover --start-directory=tests --pattern="test_*.py" + coverage erase + batch=0 + for dir in "."; do + subdirs=$(find "tests/$dir" -mindepth 1 -maxdepth 1 -type d ! -name "__pycache__" ! -name "data") + if [ -n "$subdirs" ]; then + for subdir in $subdirs; do + echo "Running coverage for tests in $subdir" + PYTHONWARNINGS="ignore::DeprecationWarning" coverage run --branch --data-file=".coverage.$batch" -m unittest discover --start-directory="$subdir" --pattern="test_*.py" + batch=$((batch + 1)) + done + else + echo "Running coverage for tests in tests/$dir" + coverage run --branch --data-file=".coverage.$batch" -m unittest discover --start-directory=tests/"$dir" --pattern="test_*.py" + batch=$((batch + 1)) + fi + done + coverage combine coverage report --show-missing --fail-under=$COVERAGE_MIN_PERC | tee coverage-${{ inputs.python-version }}.log COVERAGE_PERC=$(grep "TOTAL" coverage-${{ inputs.python-version }}.log | grep -Eo '[0-9.]+%' | sed 's/%//') echo "Coverage: $COVERAGE_PERC%" From b76c0909635e8f12776ed8de025fd0b202e72f9a Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 17:23:17 +0200 Subject: [PATCH 29/36] [214-changing-rpc-constants-on-proxy] Test running first the regular unittests to see if those also hang - i.e. is the problem with coverage --- .github/workflows/reusable-ci-workflows.yml | 5 +++-- pyproject.toml | 2 +- tests/instruments/thorlabs/test_tsp01.py | 17 +++++++++-------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reusable-ci-workflows.yml b/.github/workflows/reusable-ci-workflows.yml index d068faf3..14c6da5c 100644 --- a/.github/workflows/reusable-ci-workflows.yml +++ b/.github/workflows/reusable-ci-workflows.yml @@ -63,14 +63,15 @@ jobs: - name: Run coverage timeout-minutes: 15 run: | - coverage erase + # First, run tests WITHOUT coverage to see if they hang + python -m unittest discover --start-directory=tests --pattern="test_*.py" -v batch=0 for dir in "."; do subdirs=$(find "tests/$dir" -mindepth 1 -maxdepth 1 -type d ! -name "__pycache__" ! -name "data") if [ -n "$subdirs" ]; then for subdir in $subdirs; do echo "Running coverage for tests in $subdir" - PYTHONWARNINGS="ignore::DeprecationWarning" coverage run --branch --data-file=".coverage.$batch" -m unittest discover --start-directory="$subdir" --pattern="test_*.py" + coverage run --branch --data-file=".coverage.$batch" -m unittest discover --start-directory="$subdir" --pattern="test_*.py" batch=$((batch + 1)) done else diff --git a/pyproject.toml b/pyproject.toml index fd6c12fb..24e5c7f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ "twine", # For static code checks "astroid", - "coverage==7.13.5", + "coverage", "mypy", "ruff", # For generating documentation diff --git a/tests/instruments/thorlabs/test_tsp01.py b/tests/instruments/thorlabs/test_tsp01.py index 389f5cad..d999d28b 100644 --- a/tests/instruments/thorlabs/test_tsp01.py +++ b/tests/instruments/thorlabs/test_tsp01.py @@ -1,24 +1,25 @@ import unittest, unittest.mock -from typing import cast -from qmi.instruments.thorlabs import Thorlabs_Tsp01 -from qmi.core.transport_usbtmc_visa import QMI_VisaUsbTmcTransport +import logging + import qmi.core.exceptions +from qmi.instruments.thorlabs import Thorlabs_Tsp01 from qmi.utils.context_managers import open_close +from tests.patcher import PatcherQmiContext as QMI_Context class TestThorlabsTsp01(unittest.TestCase): def setUp(self): - qmi.start("TestTsp01Context") - self._transport_mock = unittest.mock.MagicMock(spec=QMI_VisaUsbTmcTransport) + self.ctx = QMI_Context("TestTsp01Context") + self.ctx.start() + self._transport_mock = unittest.mock.MagicMock() with unittest.mock.patch( 'qmi.instruments.thorlabs.tsp01.create_transport', return_value=self._transport_mock): - self.instr: Thorlabs_Tsp01 = qmi.make_instrument("instr", Thorlabs_Tsp01, "transport_descriptor") - self.instr = cast(Thorlabs_Tsp01, self.instr) + self.instr = Thorlabs_Tsp01(self.ctx, "instr", "transport_descriptor") def tearDown(self): - qmi.stop() + self.ctx.stop() def test_open_close(self): """Test opening and closing the instrument""" From bf1bfd7b1682bb9f3f1f6d144d3f18d7fc991fed Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 17:31:46 +0200 Subject: [PATCH 30/36] [214-changing-rpc-constants-on-proxy] Now run unittests specifically only on test_rpc.TestRPC.test_blocking_rpc only, as that seems to be the test that hangs. --- .github/workflows/reusable-ci-workflows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-ci-workflows.yml b/.github/workflows/reusable-ci-workflows.yml index 14c6da5c..70a684a4 100644 --- a/.github/workflows/reusable-ci-workflows.yml +++ b/.github/workflows/reusable-ci-workflows.yml @@ -64,7 +64,7 @@ jobs: timeout-minutes: 15 run: | # First, run tests WITHOUT coverage to see if they hang - python -m unittest discover --start-directory=tests --pattern="test_*.py" -v + timeout 120 python -m unittest tests.core.test_rpc.TestRPC.test_blocking_rpc -v 2>&1 || true batch=0 for dir in "."; do subdirs=$(find "tests/$dir" -mindepth 1 -maxdepth 1 -type d ! -name "__pycache__" ! -name "data") From 92a5b15c192de7597bfa76d4457debaf7c941ac8 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Mon, 29 Jun 2026 17:49:39 +0200 Subject: [PATCH 31/36] [214-changing-rpc-constants-on-proxy] Editing RPC error catching such that the traceback is first turned into a tuple of strings before sending it to the RpcFuture, not after. --- qmi/core/rpc.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index ce7b68ed..2d0d7281 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -517,8 +517,10 @@ def send_property_rpc_request_message( self._context.send_message(request) except QMI_MessageDeliveryException as exc: - self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, (exc, - traceback.extract_tb(exc.__traceback__))) + self._set_result( + QMI_RpcFutureState.RESULT_IS_EXCEPTION, (exc, + tuple(traceback.format_list(traceback.extract_tb(exc.__traceback__))) + )) def send_lock_rpc_request_message(self, action: QMI_LockRpcAction) -> None: request = QMI_LockRpcRequestMessage(self.address, self.rpc_object_address, self.lock_token, action) @@ -526,8 +528,10 @@ def send_lock_rpc_request_message(self, action: QMI_LockRpcAction) -> None: self._context.send_message(request) except QMI_MessageDeliveryException as exc: - self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, (exc, - traceback.extract_tb(exc.__traceback__))) + self._set_result( + QMI_RpcFutureState.RESULT_IS_EXCEPTION, (exc, + tuple(traceback.format_list(traceback.extract_tb(exc.__traceback__))) + )) def handle_message(self, message: QMI_Message) -> None: """Called when a reply message is received.""" @@ -545,9 +549,9 @@ def handle_message(self, message: QMI_Message) -> None: # Delivery of RPC request failed. exc = QMI_MessageDeliveryException(message.error_msg) self._set_result( - QMI_RpcFutureState.RESULT_IS_EXCEPTION, - (exc, traceback.extract_tb(exc.__traceback__)) - ) + QMI_RpcFutureState.RESULT_IS_EXCEPTION, (exc, + tuple(traceback.format_list(traceback.extract_tb(exc.__traceback__))) + )) else: _logger.error( "Future for %s.%s received unexpected message type %r", @@ -611,18 +615,17 @@ def wait(self, timeout: float | None = None) -> Any: if not isinstance(self._result, BaseException): raise QMI_RuntimeException("Received invalid exception value from RPC call.") raise self._result + exc = self._result[0] - tb = self._result[1] + tb_str_tuple = self._result[1] if not isinstance(exc, BaseException): raise QMI_RuntimeException("Received invalid exception value from RPC call.") from exc - tb_str_list = traceback.format_list(tb) _logger.error("Error occurred during RPC call. Traceback from RPC method:\n") - _logger.error("".join(tb_str_list)) + _logger.error("".join(tb_str_tuple)) _logger.error(exc) raise exc - if self._state == QMI_RpcFutureState.RESULT_IS_VALUE: return self._result elif self._state == QMI_RpcFutureState.OBJECT_IS_LOCKED: @@ -1459,7 +1462,7 @@ def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QM _logger.debug("RPC method call failed", exc_info=True) result_type = QMI_RpcFutureState.RESULT_IS_EXCEPTION # return the exception, and the traceback - result = (exception, traceback.extract_tb(exception.__traceback__)) + result = (exception, tuple(traceback.format_list(traceback.extract_tb(exception.__traceback__)))) else: _logger.error("%s locked, method request without lock token is denied.", self._rpc_object._name) result_type = QMI_RpcFutureState.OBJECT_IS_LOCKED From 84bcd27c9d521bda12ba1a01461b4b2f905d6ec1 Mon Sep 17 00:00:00 2001 From: Badge Bot <> Date: Mon, 29 Jun 2026 15:56:52 +0000 Subject: [PATCH 32/36] Update badges --- .github/badges/coverage.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index efe04809..86de2705 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -17,7 +17,7 @@ coverage - 92% - 92% + 95% + 95% From f6c0903b0ea0c33727fa2dcb572dc26c3a5ec126 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Tue, 30 Jun 2026 09:29:28 +0200 Subject: [PATCH 33/36] [214-changing-rpc-constants-on-proxy] Remove extra test line in coverage CI step. --- .github/workflows/reusable-ci-workflows.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/reusable-ci-workflows.yml b/.github/workflows/reusable-ci-workflows.yml index 70a684a4..3350a279 100644 --- a/.github/workflows/reusable-ci-workflows.yml +++ b/.github/workflows/reusable-ci-workflows.yml @@ -63,8 +63,6 @@ jobs: - name: Run coverage timeout-minutes: 15 run: | - # First, run tests WITHOUT coverage to see if they hang - timeout 120 python -m unittest tests.core.test_rpc.TestRPC.test_blocking_rpc -v 2>&1 || true batch=0 for dir in "."; do subdirs=$(find "tests/$dir" -mindepth 1 -maxdepth 1 -type d ! -name "__pycache__" ! -name "data") From 52f5b94a230bc06745e8cbb1b497f3bbc2c9c298 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 13 Aug 2026 10:42:43 +0200 Subject: [PATCH 34/36] [changing-rpc-constant-on-proxy] Added into rpc.py docstring a section about the RPC properties and changed one exception type in the code. Added a unit-test to check this exception. --- qmi/core/rpc.py | 45 +++++++++++++++++++++++++++++++++++++++--- tests/core/test_rpc.py | 20 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index 2d0d7281..1a105199 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -58,6 +58,45 @@ def square(self, x): proxy = qmi.context().get_rpc_object_by_name("other_context.my_object") y = proxy.square(5) + +Defining RPC properties +####################### + +Classes inheriting from `QMI_RpcObject` can also have mutable class variables, +called RPC properties. They can be defined by setting a class variable, and +adding its name in a "_rpc_properties" class variable, which is a set:: + + class MyClass(QMI_RpcObject): + + _rpc_properties = {"STRING_PROPERTY", "DICT_PROPERTY", "LIST_OF_VALUES"} + + STRING_PROPERTY = "hello there!" + DICT_PROPERTY = {"change": 1, "my": 2, "values": 3} + LIST_OF_VALUES = [1, "list of", 2.0] + NOT_AN_RPC_PROPERTY = True + +After obtaining proxy instance for the class, the property values can be changed. +For example:: + + proxy = qmi.context().make_rpc_object("my_object", MyClass, ...) + proxy.STRING_PROPERTY = "Oh, hello!" + proxy.DICT_PROPERTY = {"change": 2, "my": 3, "values": 1} + proxy.LIST_OF_VALUES = [2, "from list", 1.0] + +Trying to set a new value for `proxy.NOT_AN_RPC_PROPERTY` will raise an exception +as that class attribute was not defined as an RPC property and hence will not be +present in the proxy instance. + +Further, for stringent functioning of the parent class and to avoid various errors, +the new value for the property _must be_ of _same type_ as the original property. +For lists (or sets or tuples), the length of the new list must be equal to the +original property length, and the values in the list must of the same type, in same +order, as the original. For dictionaries, the new dictionary must be of same length +as the original and also must have the exact same keys. Only the values of a +dictionary can be changed. Also, changing only one or two values of a list, set, +tuple or dictionary of length 3 can be done only by also including the 3rd value or +key-value pair with the original value in the new list, set, tuple or dictionary. + Locking RPC objects ################### @@ -1207,10 +1246,10 @@ def _check_rpc_properties( isinstance(cls_items[name], (property, staticmethod, classmethod)) ): _logger.error( - f"RPC Property name `{name}` is invalid. Check that the name is not a " + + f"RPC property name `{name}` is invalid. Check that the name is not a " + "protected name, QMI_Signal object, [internal] function, property nor a dunder variable name." ) - raise QMI_UsageException(f"Invalid RPC Property name `{name}`.") + raise QMI_UsageException(f"Invalid RPC property name `{name}`.") def make_interface_descriptor( @@ -1438,7 +1477,7 @@ def _check_and_modify_property(self, request: QMI_PropertyRpcRequestMessage) -> return property if not check_value_structures_equal(property, request.property_value): - raise QMI_UnknownRpcException("New RPC Property value is of different type or size than original.") + raise QMI_UsageException("New RPC property value is of different type or size than original.") setattr(self._rpc_object, request.property_name, request.property_value) diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 06dc3236..0902f7d1 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -417,6 +417,26 @@ def test_properties(self): self.assertEqual(proxy1.PROPERTY_STRING, "changed") self.assertEqual(proxy2.PROPERTY_STRING, "changed") + def test_set_property_with_mismatched_type_raises(self): + """Test that setting an RPC property to a value of a different type or size raises QMI_UsageException.""" + # Make instance of MyRpcSubClass in the first context. + proxy1 = self.c1.make_rpc_object("tc1", MyRpcSubClass) + + # Make a proxy via the second context. + proxy2 = self.c2.get_rpc_object_by_name("c1.tc1") + + # Setting a property to a value of a different type is rejected, locally... + with self.assertRaises(QMI_UsageException): + proxy1.PROPERTY_NUMBER = "not_a_number" + + # ... and remotely. + with self.assertRaises(QMI_UsageException): + proxy2.PROPERTY_STRING = 12345 + + # The property values are unchanged after the rejected assignments. + self.assertEqual(proxy1.PROPERTY_NUMBER, 42) + self.assertEqual(proxy2.PROPERTY_STRING, "testing") + # Check that non-exported constants are not accessible. with self.assertRaises(AttributeError): proxy1.CONSTANT_FLOAT() From 5b6c3161d122572cdb07d672e235b8f8b3917f1b Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 13 Aug 2026 10:52:06 +0200 Subject: [PATCH 35/36] [changing-rpc-constants-on-proxy] Small (doc)string edits. --- qmi/core/rpc.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py index 1a105199..1a20f12e 100644 --- a/qmi/core/rpc.py +++ b/qmi/core/rpc.py @@ -211,7 +211,7 @@ class MyClass(QMI_RpcObject): class RpcPropertyDescriptor(NamedTuple): - """Description of an RPC Property. + """Description of an RPC property. Attributes: name: Name of the property. @@ -256,7 +256,7 @@ class RpcInterfaceDescriptor(NamedTuple): rpc_class_module: Name of the module in which the RPC object class was defined. rpc_class_name: Name of the RPC object class. rpc_class_docstring: Docstring of the RPC object class. - properties: A list of property descriptors for the RPC Properties declared + properties: A list of property descriptors for the RPC properties declared by the RPC object class. methods: A list of method descriptors for the RPC methods declared by the RPC object class. @@ -370,7 +370,7 @@ def __init__( class QMI_PropertyRpcRequestMessage(QMI_RequestMessage): - """Message sent by an RPC client to get or change a RPC Property value of a remote object. + """Message sent by an RPC client to get or change a RPC property value of a remote object. See `QMI_PropertyRpcReplyMessage` for how to interpret the reply to a request. @@ -579,7 +579,7 @@ def handle_message(self, message: QMI_Message) -> None: # Received result from RPC method call. self._set_result(message.state, message.result) elif isinstance(message, QMI_PropertyRpcReplyMessage): - # Received result from RPC Property call. + # Received result from RPC property call. self._set_result(message.state, message.result) elif isinstance(message, QMI_LockRpcReplyMessage): # Response to lock request message. @@ -1269,7 +1269,7 @@ def make_interface_descriptor( Raises: QMI_UsageException: If trying to use any of the protected RPC lock method names in the RPC object. - QMI_UsageException: If trying to set an RPC Property that is already defined as a signal or + QMI_UsageException: If trying to set an RPC property that is already defined as a signal or [RPC] method or protected lock method name. """ protected_method_names = ("lock", "unlock", "force_unlock", "is_locked") @@ -1318,7 +1318,7 @@ def make_interface_descriptor( if hasattr(base, "_rpc_properties"): base_rpc_properties = getattr(base, "_rpc_properties") - # Check validity of RPC Property name[s] + # Check validity of RPC property name[s] _check_rpc_properties(base, base_rpc_properties, protected_method_names) property_names.update(base_rpc_properties) @@ -1430,10 +1430,10 @@ def _handle_lock_rpc_request(self, request: QMI_LockRpcRequestMessage) -> QMI_Lo return reply def _handle_property_rpc_request(self, request: QMI_PropertyRpcRequestMessage) -> QMI_PropertyRpcReplyMessage: - """Handle RPC Property request.""" + """Handle RPC property request.""" assert self._rpc_object is not None - # RPC Property call - need to check if the caller may invoke the RPC Property: allowed if the object is not + # RPC property call - need to check if the caller may invoke the RPC property: allowed if the object is not # locked (token is None) or if the provided lock token matches the locking token. if self._locking_token is None or self._locking_token == request.lock_token: # Modify the property; this can raise an exception or return a result. @@ -1443,7 +1443,7 @@ def _handle_property_rpc_request(self, request: QMI_PropertyRpcRequestMessage) - result = property except BaseException as exception: - _logger.debug("RPC Property modify failed", exc_info=True) + _logger.debug("Modifying RPC property failed", exc_info=True) result_type = QMI_RpcFutureState.RESULT_IS_EXCEPTION result = exception @@ -1465,7 +1465,7 @@ def _check_and_modify_property(self, request: QMI_PropertyRpcRequestMessage) -> """Check if the object has the property requested and is RPC-able; if so, return it.""" assert self._rpc_object is not None - # Check that the property was marked as RPC Property. + # Check that the property was marked as RPC property. if not hasattr(self._rpc_object, request.property_name): raise QMI_UnknownRpcException( f"Object {request.destination_address.object_id} of type {type(self._rpc_object).__name__}" +\ From 7ab33a3713d5aa9a2c8d1ebc8ef1c996397667a5 Mon Sep 17 00:00:00 2001 From: Henri Ervasti Date: Thu, 13 Aug 2026 11:09:00 +0200 Subject: [PATCH 36/36] [changing-rpc-constants-on-proxy] Fixing a brittle Hydraharp events test by using deterministic set of values instead of random values. --- .../picoquant/test_hydraharp_event_processing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/instruments/picoquant/test_hydraharp_event_processing.py b/tests/instruments/picoquant/test_hydraharp_event_processing.py index 7ce71575..fdbbca4c 100644 --- a/tests/instruments/picoquant/test_hydraharp_event_processing.py +++ b/tests/instruments/picoquant/test_hydraharp_event_processing.py @@ -530,9 +530,10 @@ def test_get_events_limit(self): final_block_events = 10101 events_per_call = PicoQuant_HydraHarp400.MAX_EVENTS_PER_CALL num_events = 2 * events_per_call + final_block_events + rng = np.random.default_rng(12345) events_in = np.empty(num_events, dtype=EventDataType) events_in["type"] = 0 - events_in["timestamp"] = np.cumsum(np.random.randint(1, 1000, num_events) * int(resolution.value)) + events_in["timestamp"] = np.cumsum(rng.integers(1, 1000, num_events) * int(resolution.value)) # The events will be passed to the driver in smaller series, in blocks of TTREADMAX. # Calculate the sync moments and add into the expected events that should come out expected_events = np.array(events_in, dtype=EventDataType) @@ -609,8 +610,7 @@ def test_get_events_limit(self): # Get the final batch of events. Reduce the added sync events from total count events = self._hydraharp.get_events() - self.assertEqual(len(events) - np.count_nonzero(expected_events["type"] == 64), final_block_events + 1) - self.assertTrue(np.all(events == expected_events[2*events_per_call-delta:])) + self.assertTrue(np.array_equal(events, expected_events[2*events_per_call-delta:])) # Check no further events. events = self._hydraharp.get_events()