"""Discover the publishable properties of a pymeasure instrument and name their channels."""
from __future__ import annotations
import inspect
import re
from dataclasses import dataclass
from typing import Any, Literal
from pymeasure.instruments import Channel, Instrument
from pymeasure.instruments.common_base import CommonBase
try:
from pymeasure.instruments.generic_types import SCPIMixin
_BASE_CLASSES: tuple[type, ...] = (CommonBase, Instrument, Channel, SCPIMixin)
except ImportError: # pymeasure < 0.14 has no SCPIMixin
_BASE_CLASSES = (CommonBase, Instrument, Channel)
PropertyKind = Literal["control", "measurement", "setting", "property"]
_MISSING = object()
# Housekeeping properties (`id`, `status`, `next_error`, …) that every pymeasure instrument inherits.
BASE_NAMES = frozenset(name for base in _BASE_CLASSES for name in dir(base))
[docs]
@dataclass(frozen=True)
class ChannelSpec:
"""One publishable property of a pymeasure instrument.
Attributes:
path: Attribute path from the root instrument, e.g. ``"ch_1.voltage_setpoint"``.
kind: ``"control"`` (read and write), ``"measurement"`` (read only), ``"setting"``
(write only), or ``"property"`` (a hand-written Python property).
readable: Reading the property queries the instrument.
writable: Assigning the property sends a command.
get_command: SCPI query template, or ``None``.
set_command: SCPI command template, or ``None``.
doc: First line of the property's docstring.
default_name: Channel name the inspection rules give this property, without the
instrument-name prefix or ``.cmd`` suffix.
"""
path: str
kind: PropertyKind
readable: bool
writable: bool
get_command: str | None
set_command: str | None
doc: str
default_name: str
[docs]
def default_channel_name(path: str) -> str:
"""Return the channel name the inspection rules give an attribute path.
pymeasure channel attributes ``ch_<id>`` become ``ch<id>`` to match instro's naming, and any
character outside ``[A-Za-z0-9_]`` becomes ``_``.
Args:
path: Attribute path, e.g. ``"ch_1.voltage_setpoint"``.
Returns:
Dotted channel name, e.g. ``"ch1.voltage_setpoint"``.
"""
segments = []
for segment in path.split("."):
segment = re.sub(r"^ch_(\w+)$", r"ch\1", segment)
segments.append(re.sub(r"\W", "_", segment))
return ".".join(segments)
def original_class(cls: type) -> type:
"""Return the pymeasure class behind a class the wrapper generated, or ``cls`` itself."""
return getattr(cls, "_instro_original_class", cls)
[docs]
def describe(target: CommonBase | type[CommonBase]) -> list[ChannelSpec]:
"""List every publishable property of a pymeasure instrument, including its channels.
Pass an instance where possible: channels a driver adds in ``__init__`` (rather than with
``ChannelCreator``/``MultiChannelCreator`` at class level) only exist on an instance.
Properties inherited from pymeasure's base classes (``id``, ``status``, ``next_error``, …)
are left out.
Args:
target: A pymeasure instrument instance or class.
Returns:
One `ChannelSpec` per property, instrument properties first, then each channel's.
"""
specs: list[ChannelSpec] = []
_walk(target, "", specs, set())
return specs
def _walk(target: Any, prefix: str, specs: list[ChannelSpec], seen: set[int]) -> None:
is_class = inspect.isclass(target)
# Only instances can form cycles; classes repeat legitimately (ch_1..ch_3 sharing one channel class).
if not is_class:
if id(target) in seen:
return
seen.add(id(target))
cls = original_class(target if is_class else type(target))
for name in sorted(dir(cls)):
if name.startswith("_") or name in BASE_NAMES:
continue
attr = inspect.getattr_static(cls, name)
if isinstance(attr, property):
spec = _spec_for(prefix + name, attr)
if spec is not None:
specs.append(spec)
for child_name, child in _children(target, cls):
_walk(child, f"{prefix}{child_name}.", specs, seen)
def child_attributes(obj: CommonBase) -> list[tuple[str, CommonBase]]:
"""Return ``(attribute name, child)`` for each channel or other child object of a pymeasure instance."""
return [
(name, value)
for name, value in list(vars(obj).items())
if isinstance(value, CommonBase) and name != "parent" and not name.startswith("_")
]
def _children(target: Any, cls: type) -> list[tuple[str, Any]]:
"""Return ``(attribute name, child)`` pairs: instances from ``vars``, classes from channel creators."""
if not inspect.isclass(target):
return list(child_attributes(target))
children: list[tuple[str, Any]] = []
for name, creator in CommonBase.get_channels(cls):
for child_cls, child_id in creator.pairs:
prefix = creator.kwargs.get("prefix")
if isinstance(creator, CommonBase.MultiChannelCreator) and prefix and child_id is not None:
children.append((f"{prefix}{child_id}", child_cls))
else:
children.append((name, child_cls))
return children
def _command(fn: Any, param: str) -> Any:
if fn is None:
return _MISSING
try:
parameter = inspect.signature(fn).parameters.get(param)
except (TypeError, ValueError):
return _MISSING
return _MISSING if parameter is None else parameter.default
def _spec_for(path: str, prop: property) -> ChannelSpec | None:
get_command = _command(prop.fget, "get_command")
set_command = _command(prop.fset, "set_command")
kind: PropertyKind
if get_command is _MISSING and set_command is _MISSING:
readable, writable = prop.fget is not None, prop.fset is not None
get_command = set_command = None
kind = "property"
else:
get_command = None if get_command is _MISSING else get_command
set_command = None if set_command is _MISSING else set_command
readable, writable = get_command is not None, set_command is not None
kind = "control" if readable and writable else "measurement" if readable else "setting"
if not (readable or writable):
return None
# pymeasure appends "(dynamic)" to dynamic properties' docstrings.
doc = (inspect.getdoc(prop) or "").removesuffix("(dynamic)").strip().splitlines()
return ChannelSpec(
path=path,
kind=kind,
readable=readable,
writable=writable,
get_command=get_command if isinstance(get_command, str) else None,
set_command=set_command if isinstance(set_command, str) else None,
doc=doc[0] if doc else "",
default_name=default_channel_name(path),
)