diff --git a/.coveragerc b/.coveragerc
index f6c5c9ec..6ae8b547 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,3 +1,3 @@
[run]
core = ctrace
-branch = True
+branch = True
\ No newline at end of file
diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg
index d283ae09..86de2705 100644
--- a/.github/badges/coverage.svg
+++ b/.github/badges/coverage.svg
@@ -17,7 +17,7 @@
coverage
- 91%
- 91%
+ 95%
+ 95%
diff --git a/.github/workflows/reusable-ci-workflows.yml b/.github/workflows/reusable-ci-workflows.yml
index c84a5f27..3350a279 100644
--- a/.github/workflows/reusable-ci-workflows.yml
+++ b/.github/workflows/reusable-ci-workflows.yml
@@ -61,8 +61,24 @@ 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"
+ 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"
+ 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%"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37a9c690..0a375a02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,16 @@ 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 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
- Improvement on exception messages from proxy calls: Exceptions caught in `qmi.core.rpc.QMI_RpcFuture` are now handled with `traceback` to extract the traceback from the exception. The traceback is sent along with the exception to `self._result` of the class so that also the full traceback of the exception can be logged, not just the exception raised.
+- 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
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/documentation/sphinx/source/tutorial.rst b/documentation/sphinx/source/tutorial.rst
index 37ecc3f8..a7c511b8 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 properties 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 Properties:
+ - max_frequency: float = 1000000.0
+ - max_amplitude: float = 1000.0
+ - max_wait: = 10.0
+
+
+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 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
+ 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`, not included in RPC Properties, 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 ".\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()
@@ -92,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()
@@ -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 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.
@@ -116,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
---------------------
@@ -166,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
diff --git a/qmi/core/rpc.py b/qmi/core/rpc.py
index e95a4fd2..1a20f12e 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
###################
@@ -136,30 +175,32 @@ 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 traceback
-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,
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.
@@ -169,12 +210,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
@@ -184,9 +225,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
@@ -198,7 +239,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
@@ -212,21 +253,20 @@ 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.
+ 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.
+ 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
rpc_class_docstring: str | None
- constants: list[RpcConstantDescriptor]
+ properties: list[RpcPropertyDescriptor]
methods: list[RpcMethodDescriptor]
signals: list[RpcSignalDescriptor]
@@ -235,11 +275,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
@@ -284,17 +323,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
@@ -318,16 +358,68 @@ 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_PropertyRpcRequestMessage(QMI_RequestMessage):
+ """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.
+
+ Attributes:
+ 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__ = ("property_name", "property_value", "set_property_value", "lock_token")
+
+ def __init__(
+ self,
+ source_address: QMI_MessageHandlerAddress,
+ destination_address: QMI_MessageHandlerAddress,
+ property_name: str,
+ property_value: Any,
+ set_property_value: bool,
+ lock_token: QMI_LockTokenDescriptor | None = None
+ ) -> None:
+ super().__init__(source_address, destination_address)
+ self.property_name = property_name
+ self.property_value = property_value
+ self.set_property_value = set_property_value
+ self.lock_token = lock_token
+
+
+class QMI_PropertyRpcReplyMessage(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.
@@ -339,14 +431,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
@@ -358,18 +451,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
@@ -390,11 +484,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)
@@ -407,16 +502,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(
@@ -430,23 +526,60 @@ 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_property_rpc_request_message(
+ self,
+ 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 property.
+
+ Parameters:
+ 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_PropertyRpcRequestMessage(
+ self.address,
+ self.rpc_object_address,
+ rpc_property_name,
+ rpc_property_value,
+ set_property_value,
+ self.lock_token
+ )
+
+ try:
+ self._context.send_message(request)
+
except QMI_MessageDeliveryException as exc:
- self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, (exc,
- traceback.extract_tb(exc.__trackback__)))
+ 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)
try:
self._context.send_message(request)
+
except QMI_MessageDeliveryException as exc:
- self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION, (exc,
- traceback.extract_tb(exc.__trackback__)))
+ 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."""
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_PropertyRpcReplyMessage):
+ # Received result from RPC property call.
self._set_result(message.state, message.result)
elif isinstance(message, QMI_LockRpcReplyMessage):
# Response to lock request message.
@@ -454,31 +587,40 @@ def handle_message(self, message: QMI_Message) -> None:
elif isinstance(message, QMI_ErrorReplyMessage):
# Delivery of RPC request failed.
exc = QMI_MessageDeliveryException(message.error_msg)
- self._set_result(QMI_RpcFutureState.RESULT_IS_EXCEPTION,
- (exc, traceback.extract_tb(exc.__trackback__))
- )
+ self._set_result(
+ 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",
- 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
@@ -495,8 +637,8 @@ def wait(self, timeout: float | None = None) -> Any:
The return value from the associated RPC method call.
Raises:
- QMI_RuntimeException: If received a custom exception value not derived from BaseException from RPC call.
- QMI_RuntimeException: If the object was locked by another proxy.
+ 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.
"""
if timeout is not None:
@@ -510,24 +652,23 @@ def wait(self, timeout: float | None = None) -> Any:
if not isinstance(self._result, tuple):
# happens when we connect to an older QMI version
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
+
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:
- 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
@@ -547,13 +688,29 @@ 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_property_call(
+ context: "qmi.core.context.QMI_Context",
+ rpc_object_address: QMI_MessageHandlerAddress,
+ property_name: str,
+ rpc_lock_token: QMI_LockTokenDescriptor | None,
+ property_value: Any = None,
+ *,
+ set_property_value: bool = False
+) -> Any:
+ """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_property_rpc_request_message(property_name, property_value, set_property_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.")
@@ -563,14 +720,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)
@@ -607,8 +765,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:
@@ -616,9 +775,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
@@ -648,30 +806,30 @@ 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_property_names = frozenset(
+ property_descriptor.name for property_descriptor in descriptor.interface.properties
+ )
- # 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_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)
-
# 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
@@ -694,9 +852,49 @@ def make_rpc_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_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,
+ 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_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_property_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
@@ -714,7 +912,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:
@@ -797,8 +995,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:
@@ -876,9 +1076,9 @@ 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
- a list of attribute names to be exported.
+ 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
to invoke methods of the `QMI_RpcObject` directly from outside the class.
@@ -892,6 +1092,8 @@ class QMI_RpcObject(metaclass=_RpcObjectMetaClass):
into the QMI network and routed to subscribed receivers.
"""
+ _rpc_properties: set[str]
+
@classmethod
def get_category(cls) -> str | None:
"""Return the optional name of the category this object belongs to.
@@ -902,32 +1104,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)
@@ -1026,15 +1226,53 @@ 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 _check_rpc_properties(
+ cls: Type[QMI_RpcObject], rpc_property_names: list[str], protected_names: tuple[str, ...]
+) -> None:
+ """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 property names may include only class properties.
+ """
+ # 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_property_names:
+ if (
+ name in protected_names 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 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}`.")
+
+
+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
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 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
+ [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
# provided by the caller.
if signal_declaration_class is None:
@@ -1046,8 +1284,8 @@ def make_interface_descriptor(rpc_object_class: Type[QMI_RpcObject],
# 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"):
- raise QMI_UsageException(f"`{name}` is a protected method name")
+ if name in protected_method_names:
+ raise QMI_UsageException(f"`{name}` is a protected method name.")
signature = str(inspect.signature(member))
docstring = member.__doc__
@@ -1065,25 +1303,36 @@ def make_interface_descriptor(rpc_object_class: Type[QMI_RpcObject],
signals.append(RpcSignalDescriptor(name, arg_types))
doc += f" - {name}{arg_types}\n"
- # Extract constant declarations.
- constant_names = set()
+ # 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"):
- constant_names.update(getattr(base, "_rpc_constants"))
-
- # 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}={constant_value}\n"
+ 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]
+ _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
)
@@ -1098,10 +1347,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
@@ -1109,7 +1359,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."""
@@ -1122,13 +1372,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
@@ -1138,17 +1390,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.
@@ -1175,6 +1429,60 @@ 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."""
+ 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
+ # 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.
+ try:
+ property = self._check_and_modify_property(request)
+ result_type = QMI_RpcFutureState.RESULT_IS_VALUE
+ result = property
+
+ except BaseException as exception:
+ _logger.debug("Modifying RPC property failed", exc_info=True)
+ result_type = QMI_RpcFutureState.RESULT_IS_EXCEPTION
+ result = exception
+
+ else:
+ _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_PropertyRpcReplyMessage(
+ 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_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 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 property {request.property_name}."
+ )
+
+ property = getattr(self._rpc_object, request.property_name)
+ if not request.set_property_value:
+ return property
+
+ if not check_value_structures_equal(property, request.property_value):
+ 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)
+
+ return getattr(self._rpc_object, request.property_name)
+
def _handle_method_rpc_request(self, request: QMI_MethodRpcRequestMessage) -> QMI_MethodRpcReplyMessage:
"""Handle RPC method request."""
assert self._rpc_object is not None
@@ -1184,7 +1492,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
# return whatever the method should return
result = method(*request.method_args, **request.method_kwargs)
@@ -1193,9 +1501,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
# 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)
+ _logger.error("%s locked, method request without lock token is denied.", self._rpc_object._name)
result_type = QMI_RpcFutureState.OBJECT_IS_LOCKED
result = None
@@ -1208,16 +1516,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) -> Callable:
"""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)
@@ -1238,20 +1546,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_PropertyRpcRequestMessage, 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.
@@ -1288,6 +1603,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)
@@ -1318,9 +1634,11 @@ def run(self) -> None:
request = self._fifo.popleft()
# Process request.
- reply: QMI_MethodRpcReplyMessage | 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_PropertyRpcRequestMessage):
+ reply = self._handle_property_rpc_request(request)
elif isinstance(request, QMI_LockRpcRequestMessage):
reply = self._handle_lock_rpc_request(request)
else:
@@ -1347,13 +1665,17 @@ 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")
_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_PropertyRpcRequestMessage | QMI_LockRpcRequestMessage | None
+ ) -> None:
"""Push an RPC request into the request queue and notify the thread."""
with self._cv:
self._fifo.append(rpc_request)
@@ -1378,18 +1700,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)
@@ -1411,6 +1734,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
@@ -1434,18 +1758,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_PropertyRpcRequestMessage, 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/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>"
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/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/adwin/adwin.py b/qmi/instruments/adwin/adwin.py
index f3e1b7c9..4d3ccabd 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_properties = {"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 9041814f..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 = ["CONDITION_BITS", "STATUS_BITS", "STATUS_MASK", "STATUS_GOOD"]
+ _rpc_properties = {"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..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 = ["FAULT_CODES", "OPERATING_MODES"]
+ _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 e059cfc4..6110179b 100644
--- a/qmi/instruments/dummy/noisy_sine_generator.py
+++ b/qmi/instruments/dummy/noisy_sine_generator.py
@@ -10,7 +10,21 @@
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.
+ max_noise: Maximum noise level that can be set. By default the same as max amplitude.
+ """
+
+ _rpc_properties = {"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 +40,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 +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 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 +88,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 +112,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/qmi/instruments/newport/ag_uc8.py b/qmi/instruments/newport/ag_uc8.py
index a3f2320e..9ea6c31b 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_properties = {
+ "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..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 = ["SENSOR_TYPES"]
+ _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 75c7ac4b..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 = ["ERROR_CODES"]
+ _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 aab0113c..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 c2e62891..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 9127ca20..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 2e683eab..58b77a8c 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_properties = {
"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..4131a05e 100644
--- a/qmi/instruments/timebase/dim3000.py
+++ b/qmi/instruments/timebase/dim3000.py
@@ -190,16 +190,16 @@ 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",
"DUTY_CYCLE_RANGE",
"AM_OFFSET_RANGE",
"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 74bdf882..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 f67a3f51..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 c4ef9e96..0902f7d1 100644
--- a/tests/core/test_rpc.py
+++ b/tests/core/test_rpc.py
@@ -3,11 +3,13 @@
import inspect
import logging
import math
+from threading import Timer
import time
from typing import NamedTuple
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 (
@@ -17,19 +19,29 @@
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):
"""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):
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."""
@@ -50,9 +62,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):
@@ -60,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):
@@ -68,7 +81,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 = []
@@ -126,7 +139,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'
@@ -147,19 +160,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:
- 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"
+ # 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
@@ -202,11 +213,15 @@ def tearDown(self):
logging.getLogger("qmi.core.rpc").setLevel(logging.NOTSET)
logging.getLogger("qmi.core.messaging").setLevel(logging.NOTSET)
+ # 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)
@@ -250,9 +265,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)
@@ -359,9 +374,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)
@@ -379,7 +394,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)
@@ -387,15 +402,95 @@ 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 properties are settable via both proxies.
+ proxy1.PROPERTY_NUMBER = 24
+ self.assertEqual(proxy1.PROPERTY_NUMBER, 24)
+ self.assertEqual(proxy2.PROPERTY_NUMBER, 24)
+
+ proxy2.PROPERTY_STRING = "changed"
+ 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):
- print(proxy1.CONSTANT_FLOAT)
+ proxy1.CONSTANT_FLOAT()
+
+ 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_properties = {"_variable_strings"}
+ with self.assertRaises(QMI_UsageException) as err:
+ self.c1.make_rpc_object("tc1", MyRpcTestClass)
+ self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception))
+
+ # Name cannot be a property
+ MyRpcTestClass._rpc_properties = {"variable_strings"}
+ with self.assertRaises(QMI_UsageException) as err:
+ self.c1.make_rpc_object("tc1", MyRpcTestClass)
+ self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception))
+
+ # Name cannot be a static method
+ MyRpcTestClass._rpc_properties = {"_call_me_maybe"}
+ with self.assertRaises(QMI_UsageException) as err:
+ self.c1.make_rpc_object("tc1", MyRpcTestClass)
+ self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception))
+
+ # Name cannot be a class method
+ MyRpcTestClass._rpc_properties = {"get_category"}
+ with self.assertRaises(QMI_UsageException) as err:
+ self.c1.make_rpc_object("tc1", MyRpcTestClass)
+ self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception))
+
+ # Name cannot be a function method
+ MyRpcTestClass._rpc_properties = {"release_rpc_object"}
+ with self.assertRaises(QMI_UsageException) as err:
+ self.c1.make_rpc_object("tc1", MyRpcTestClass)
+ self.assertIn(MyRpcTestClass._rpc_properties[0], str(err.exception))
+
+ # Name cannot be a RPC method
+ MyRpcTestClass._rpc_properties = {"remote_sqrt"}
+ with self.assertRaises(QMI_UsageException) as err:
+ self.c1.make_rpc_object("tc1", MyRpcTestClass)
+ 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_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_properties = {"mock_signal"}
+ with self.assertRaises(QMI_UsageException) as err:
+ self.c1.make_rpc_object("tc1", MyRpcSubClass)
+ self.assertIn(MyRpcSubClass._rpc_properties[0], str(err.exception))
def test_call_to_disconnected(self):
@@ -886,7 +981,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()
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/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."""
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..5528af64
--- /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 properties."""
+
+ 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_properties 'max_frequency' property 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_properties 'max_amplitude' property 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_properties."""
+ with self.assertRaises(AttributeError):
+ self.nsg.max_noise = NSG.max_noise + 2.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_properties 'max_wait' property 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)
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()
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"""