Skip to content

Presenter

Base classes

Bases: ABC

Base presenter class.

Does not inherit PPresenter, whose read-only properties would shadow the instance attributes set here. Instances satisfy the protocol by shape, like any other presenter.

Parameters:

Name Type Description Default
name str

Identity key of the presenter, positional-only.

required
devices Mapping[str, Device]

The session's devices.

required
kwargs Any

Additional keyword arguments for presenter subclasses.

{}
Source code in src/redsun/presenter/_base.py
class Presenter(ABC):
    """Base presenter class.

    Does not inherit [`PPresenter`][redsun.presenter.PPresenter], whose
    read-only properties would shadow the instance attributes set here.
    Instances satisfy the protocol by shape, like any other presenter.

    Parameters
    ----------
    name : str
        Identity key of the presenter, positional-only.
    devices : Mapping[str, ophyd_async.core.Device]
        The session's devices.
    kwargs : Any, optional
        Additional keyword arguments for presenter subclasses.
    """

    name: str
    devices: Mapping[str, Device]

    @abstractmethod
    def __init__(
        self,
        name: str,
        devices: Mapping[str, Device],
        /,
        **kwargs: Any,
    ) -> None:
        self.name = name
        self.devices = devices
        super().__init__(**kwargs)

Bases: Protocol

Protocol of a presenter component.

Members are read-only properties, so instance attributes, class attributes or properties satisfy them, and devices may be any Mapping, such as a dict.

Notes

A presenter reaches the virtual container by implementing IsProvider or IsInjectable.

Checked with isinstance on the built instance, since attributes assigned in __init__ do not exist on the class.

Source code in src/redsun/presenter/_base.py
@runtime_checkable
class PPresenter(Protocol):  # pragma: no cover
    """Protocol of a presenter component.

    Members are read-only properties, so instance attributes, class attributes
    or properties satisfy them, and ``devices`` may be any ``Mapping``, such
    as a ``dict``.

    Notes
    -----
    A presenter reaches the virtual container by implementing
    [`IsProvider`][redsun.virtual.IsProvider] or
    [`IsInjectable`][redsun.virtual.IsInjectable].

    Checked with ``isinstance`` on the built instance, since attributes
    assigned in ``__init__`` do not exist on the class.
    """

    @property
    def name(self) -> str:
        """Identity key of the presenter."""
        ...

    @property
    def devices(self) -> Mapping[str, Device]:
        """The session's devices."""
        ...

name property

name: str

Identity key of the presenter.

devices property

devices: Mapping[str, Device]

The session's devices.

Plan specification

Describe a plan's signature as a PlanSpec.

create_plan_spec inspects a bluesky MsgGenerator function and returns a PlanSpec describing its parameters, from which a view builds a parameter form.

_ANN_HANDLER_MAP lists (predicate, handler) pairs turning annotations into ParamDescription fields (choices, device_proto, multiselect).

ParamKind

Bases: IntEnum

inspect._ParameterKind as a public IntEnum.

Usable in match/case without importing private standard library names.

Source code in src/redsun/presenter/plan_spec.py
class ParamKind(IntEnum):
    """`inspect._ParameterKind` as a public `IntEnum`.

    Usable in ``match``/``case`` without importing private standard library
    names.
    """

    POSITIONAL_ONLY = 0
    POSITIONAL_OR_KEYWORD = 1
    VAR_POSITIONAL = 2
    KEYWORD_ONLY = 3
    VAR_KEYWORD = 4

UnresolvableAnnotationError

Bases: TypeError

Raised when a plan parameter's annotation maps to no widget.

Parameters:

Name Type Description Default
plan_name str

Name of the plan.

required
param_name str

Name of the parameter.

required
annotation Any

The unresolvable annotation.

required
Source code in src/redsun/presenter/plan_spec.py
class UnresolvableAnnotationError(TypeError):
    """Raised when a plan parameter's annotation maps to no widget.

    Parameters
    ----------
    plan_name : str
        Name of the plan.
    param_name : str
        Name of the parameter.
    annotation : Any
        The unresolvable annotation.
    """

    def __init__(self, plan_name: str, param_name: str, annotation: Any) -> None:
        self.plan_name = plan_name
        self.param_name = param_name
        self.annotation = annotation
        super().__init__(
            f"Plan {plan_name!r}: cannot resolve annotation for parameter "
            f"{param_name!r} ({annotation!r}). "
            f"A required parameter must be a Literal, a device protocol, a "
            f"sequence of them, a sequence of any other renderable type, or one "
            f"of int, float, str, bool, bytes, range, Path, an Enum or a "
            f"datetime type. The plan will be skipped."
        )

create_plan_spec

create_plan_spec(
    plan: Callable[..., Generator[Any, Any, Any]],
    devices: Mapping[str, Device],
) -> PlanSpec

Inspect plan and return a PlanSpec with one ParamDescription per parameter.

Parameters:

Name Type Description Default
plan Callable[..., Any]

The plan function or bound method, a generator function annotated to return a MsgGenerator.

required
devices Mapping[str, Device]

The session's devices, giving choices for parameters annotated with an OADevice subtype.

required

Returns:

Type Description
PlanSpec

The plan specification.

Raises:

Type Description
TypeError

If plan is not a generator function or its return type is not a MsgGenerator (Generator[Msg, Any, Any]).

UnresolvableAnnotationError

If an annotation names something missing at runtime, or no view can build a control for it.

RuntimeError

On an unexpected inspect.Parameter.kind.

Source code in src/redsun/presenter/plan_spec.py
def create_plan_spec(
    plan: cabc.Callable[..., cabc.Generator[Any, Any, Any]],
    devices: cabc.Mapping[str, OADevice],
) -> PlanSpec:
    """Inspect *plan* and return a ``PlanSpec`` with one ``ParamDescription`` per parameter.

    Parameters
    ----------
    plan : Callable[..., Any]
        The plan function or bound method, a generator function annotated to
        return a ``MsgGenerator``.
    devices : Mapping[str, OADevice]
        The session's devices, giving ``choices`` for parameters annotated
        with an ``OADevice`` subtype.

    Returns
    -------
    PlanSpec
        The plan specification.

    Raises
    ------
    TypeError
        If *plan* is not a generator function or its return type is not a
        ``MsgGenerator`` (``Generator[Msg, Any, Any]``).
    UnresolvableAnnotationError
        If an annotation names something missing at runtime, or no view can
        build a control for it.
    RuntimeError
        On an unexpected ``inspect.Parameter.kind``.
    """
    func_obj: cabc.Callable[..., cabc.Generator[Any, Any, Any]] = getattr(
        plan, "__func__", plan
    )

    if not inspect.isgeneratorfunction(func_obj):
        raise TypeError(f"Plan {func_obj.__name__!r} must be a generator function.")

    sig = signature(func_obj)
    type_hints, unresolved = _resolve_annotations(func_obj)

    if "return" in unresolved:
        raise UnresolvableAnnotationError(
            func_obj.__name__, "return", unresolved["return"]
        )

    return_type = type_hints.get("return", None)

    if return_type is None:
        raise TypeError(
            f"Plan {func_obj.__name__!r} must have a return type annotation."
        )

    ret_origin = get_origin(return_type)
    is_generator = ret_origin is not None and _safe_issubclass(
        ret_origin, cabc.Generator
    )
    if not is_generator:
        raise TypeError(
            f"Plan {func_obj.__name__!r} must have a MsgGenerator return type; "
            f"got {return_type!r}."
        )

    params: list[ParamDescription] = []

    for name, param in _iterate_signature(sig):
        if name in unresolved:
            raise UnresolvableAnnotationError(func_obj.__name__, name, unresolved[name])

        raw_ann: Any = type_hints.get(name, param.annotation)
        if raw_ann is _empty:
            raw_ann = Any

        if get_origin(raw_ann) is Annotated:
            ann_args = get_args(raw_ann)
            ann: Any = ann_args[0] if ann_args else Any
        else:
            ann = raw_ann

        actions_meta = _extract_action_meta(param, ann)

        pkind = _PARAM_KIND_MAP.get(param.kind)
        if pkind is None:
            raise RuntimeError(f"Unexpected parameter kind: {param.kind!r}")

        # Action parameters never get a widget, so they skip dispatch
        if actions_meta is not None:
            fields = _FieldsFromAnnotation()
        else:
            fields = _dispatch_annotation(ann, pkind, devices)

        # refuse now: failing here is clearer than a broken control or a
        # crash once the plan runs
        is_required = param.default is _empty
        needs_control = (
            actions_meta is None
            and is_required
            and pkind is not ParamKind.VAR_KEYWORD
            and fields.choices is None
        )
        if needs_control and not _is_renderable(ann):
            raise UnresolvableAnnotationError(func_obj.__name__, name, ann)

        params.append(
            ParamDescription(
                name=name,
                kind=pkind,
                annotation=ann,
                default=param.default,
                choices=fields.choices,
                multiselect=fields.multiselect,
                actions=actions_meta,
                device_proto=fields.device_proto,
            )
        )

    togglable = bool(getattr(func_obj, "__togglable__", False))
    pausable = bool(getattr(func_obj, "__pausable__", False))

    return PlanSpec(
        name=func_obj.__name__,
        docs=inspect.getdoc(func_obj) or "No documentation available.",
        parameters=params,
        togglable=togglable,
        pausable=pausable,
    )

collect_arguments

collect_arguments(
    spec: PlanSpec, values: Mapping[str, Any]
) -> tuple[tuple[Any, ...], dict[str, Any]]

Build the (args, kwargs) calling a plan, from its PlanSpec.

Parameters:

Name Type Description Default
spec PlanSpec

The plan specification.

required
values Mapping[str, Any]

Resolved values by parameter name.

required

Returns:

Type Description
tuple[tuple[Any, ...], dict[str, Any]]

Positional and keyword arguments for the plan.

Notes
  • POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD -> args, in declaration order.
  • KEYWORD_ONLY -> kwargs.
  • VAR_POSITIONAL (*args) -> sequence expanded into args.
  • VAR_KEYWORD (**kwargs) -> mapping merged into kwargs.
Source code in src/redsun/presenter/plan_spec.py
def collect_arguments(
    spec: PlanSpec,
    values: cabc.Mapping[str, Any],
) -> tuple[tuple[Any, ...], dict[str, Any]]:
    """Build the ``(args, kwargs)`` calling a plan, from its ``PlanSpec``.

    Parameters
    ----------
    spec : PlanSpec
        The plan specification.
    values : Mapping[str, Any]
        Resolved values by parameter name.

    Returns
    -------
    tuple[tuple[Any, ...], dict[str, Any]]
        Positional and keyword arguments for the plan.

    Notes
    -----
    * ``POSITIONAL_ONLY`` and ``POSITIONAL_OR_KEYWORD`` -> ``args``, in
      declaration order.
    * ``KEYWORD_ONLY`` -> ``kwargs``.
    * ``VAR_POSITIONAL`` (``*args``) -> sequence expanded into ``args``.
    * ``VAR_KEYWORD`` (``**kwargs``) -> mapping merged into ``kwargs``.
    """
    args: list[Any] = []
    kwargs: dict[str, Any] = {}

    for p in spec.parameters:
        if p.name not in values:
            continue
        value = values[p.name]

        match p.kind:
            case ParamKind.VAR_POSITIONAL:
                if isinstance(value, cabc.Sequence) and not isinstance(
                    value, (str, bytes)
                ):
                    args.extend(value)
                else:
                    args.append(value)
            case ParamKind.VAR_KEYWORD:
                if isinstance(value, cabc.Mapping):
                    kwargs.update(value)
                else:
                    raise TypeError(
                        f"Value for **{p.name} must be a Mapping, got {type(value)!r}"
                    )
            case ParamKind.POSITIONAL_ONLY | ParamKind.POSITIONAL_OR_KEYWORD:
                args.append(value)
            case ParamKind.KEYWORD_ONLY:
                kwargs[p.name] = value

    return tuple(args), kwargs

resolve_arguments

resolve_arguments(
    spec: PlanSpec,
    param_values: Mapping[str, Any],
    devices: Mapping[str, Device],
) -> dict[str, Any]

Turn parameter values from the interface into values a plan takes.

  • Action parameters are filled from the spec when the interface lacks them.
  • Device parameters: names become OADevice instances from devices.
  • Everything else passes unchanged.

Parameters:

Name Type Description Default
spec PlanSpec

The plan specification.

required
param_values Mapping[str, Any]

Parameter values from the interface.

required
devices Mapping[str, Device]

The session's devices.

required

Returns:

Type Description
dict[str, Any]

Resolved arguments for collect_arguments.

Source code in src/redsun/presenter/plan_spec.py
def resolve_arguments(
    spec: PlanSpec,
    param_values: Mapping[str, Any],
    devices: Mapping[str, OADevice],
) -> dict[str, Any]:
    """Turn parameter values from the interface into values a plan takes.

    * **Action parameters** are filled from the spec when the interface lacks
      them.
    * **Device parameters**: names become ``OADevice`` instances from
      ``devices``.
    * **Everything else** passes unchanged.

    Parameters
    ----------
    spec : PlanSpec
        The plan specification.
    param_values : Mapping[str, Any]
        Parameter values from the interface.
    devices : Mapping[str, OADevice]
        The session's devices.

    Returns
    -------
    dict[str, Any]
        Resolved arguments for ``collect_arguments``.
    """
    values: dict[str, Any] = dict(param_values)

    # action parameters have no widget, so their values never come from the UI
    for p in spec.parameters:
        if p.actions is not None and p.name not in values:
            values[p.name] = p.actions

    resolved: dict[str, Any] = {}

    for p in spec.parameters:
        if p.name not in values:
            continue
        val = values[p.name]

        if p.choices is not None and p.device_proto is not None:
            if isinstance(val, str):
                labels = [val]
            elif isinstance(val, (cabc.Sequence, cabc.Set)) and not isinstance(
                val, (str, bytes)
            ):
                labels = [str(v) for v in val]
            else:
                labels = [str(val)]

            device_list = get_choice_list(devices, p.device_proto, labels)

            if p.kind is ParamKind.VAR_POSITIONAL or isdevicesequence(p.annotation):
                resolved[p.name] = device_list
            elif isdeviceset(p.annotation):
                resolved[p.name] = set(device_list)
            else:
                resolved[p.name] = device_list[0] if device_list else None
        else:
            resolved[p.name] = val

    return resolved

ParamDescription dataclass

Description of one plan parameter.

Attributes:

Name Type Description
name str

Name of the parameter in the plan signature.

kind ParamKind

Kind of the parameter, as inspect.Parameter.kind.

annotation Any

Type annotation, without Annotated metadata.

default Any

Default value of the parameter, or inspect.Parameter.empty if none.

choices list[str] | None

Labels of selectable values, for Literal and device parameters.

multiselect bool

Whether several values can be selected, as for Sequence[OADevice].

hidden bool

Whether the parameter is hidden from the interface, as for metadata only.

actions Sequence[Action] | Action | None

Actions taken from the parameter's default value, if any.

device_proto type[Any] | None

Device class or runtime-checkable protocol of a device parameter, used to look devices up when resolving arguments.

Source code in src/redsun/presenter/plan_spec.py
@dataclass
class ParamDescription:
    """Description of one plan parameter."""

    name: str
    """Name of the parameter in the plan signature."""

    kind: ParamKind
    """Kind of the parameter, as `inspect.Parameter.kind`."""

    annotation: Any
    """Type annotation, without `Annotated` metadata."""

    default: Any
    """Default value of the parameter, or `inspect.Parameter.empty` if none."""

    choices: list[str] | None = None
    """Labels of selectable values, for `Literal` and device parameters."""

    multiselect: bool = False
    """Whether several values can be selected, as for `Sequence[OADevice]`."""

    hidden: bool = False
    """Whether the parameter is hidden from the interface, as for metadata only."""

    actions: Sequence[Action] | Action | None = None
    """Actions taken from the parameter's default value, if any."""

    device_proto: type[Any] | None = None
    """Device class or runtime-checkable protocol of a device parameter, used to look devices up when resolving arguments."""

    @property
    def has_default(self) -> bool:
        """Return ``True`` if the parameter has a default."""
        return self.default is not _empty

has_default property

has_default: bool

Return True if the parameter has a default.

PlanSpec dataclass

Description of a plan's signature and type hints.

Attributes:

Name Type Description
name str

Plan name, the callable's __name__.

docs str

Plan docstring, or a default message without one.

parameters list[ParamDescription]

One description per parameter, in order.

togglable bool

Whether the plan loops until stopped with a toggle button.

pausable bool

Whether a running togglable plan can be paused and resumed.

Source code in src/redsun/presenter/plan_spec.py
@dataclass(eq=False)
class PlanSpec:
    """Description of a plan's signature and type hints."""

    name: str
    """Plan name, the callable's ``__name__``."""

    docs: str
    """Plan docstring, or a default message without one."""

    parameters: list[ParamDescription]
    """One description per parameter, in order."""

    togglable: bool = False
    """Whether the plan loops until stopped with a toggle button."""

    pausable: bool = False
    """Whether a running togglable plan can be paused and resumed."""

Utilities

Predicates and helpers inspecting plan parameters.

create_plan_spec uses them to classify annotations, and resolve_arguments to turn device names into Device instances.

get_choice_list

get_choice_list(
    devices: Mapping[str, Device],
    proto: type[D],
    choices: Sequence[str],
) -> list[D]

Return the devices named in choices that are instances of proto.

Parameters:

Name Type Description Default
devices Mapping[str, Device]

Devices by name.

required
proto type[D]

Class checked with isinstance.

required
choices Sequence[str]

Names of the devices to consider.

required

Returns:

Type Description
list[D]

The matching devices.

Source code in src/redsun/presenter/utils.py
def get_choice_list(
    devices: Mapping[str, OADevice], proto: type[D], choices: Sequence[str]
) -> list[D]:
    """Return the devices named in *choices* that are instances of *proto*.

    Parameters
    ----------
    devices : Mapping[str, OADevice]
        Devices by name.
    proto : type[D]
        Class checked with ``isinstance``.
    choices : Sequence[str]
        Names of the devices to consider.

    Returns
    -------
    list[D]
        The matching devices.
    """
    return [
        model
        for name, model in devices.items()
        if isinstance(model, proto) and name in choices
    ]

isdevice

isdevice(ann: Any) -> bool

Return True if the annotation ann is a Device subclass.

Source code in src/redsun/presenter/utils.py
def isdevice(ann: Any) -> bool:
    """Return True if the annotation *ann* is a [`Device`][ophyd_async.core.Device] subclass."""
    return _is_device_annotation(ann)

isdevicesequence

isdevicesequence(ann: Any) -> bool

Return True if ann is Sequence[T] where T is a Device subtype.

Source code in src/redsun/presenter/utils.py
def isdevicesequence(ann: Any) -> bool:
    """Return True if *ann* is ``Sequence[T]`` where *T* is a [`Device`][ophyd_async.core.Device] subtype."""
    return issequence(ann) and _single_device_arg(ann)

issequence

issequence(ann: Any) -> bool

Return True if ann is a Sequence[...] generic alias.

Notes

str and bytes are sequences, but not generic aliases (get_origin(str) is None), so they are excluded.

Source code in src/redsun/presenter/utils.py
def issequence(ann: Any) -> bool:
    """Return True if *ann* is a ``Sequence[...]`` generic alias.

    Notes
    -----
    ``str`` and ``bytes`` are sequences, but not generic aliases
    (``get_origin(str)`` is ``None``), so they are excluded.
    """
    return _origin_subclasses(ann, Sequence)