Skip to content

Presenter

Base classes

Bases: PPresenter, ABC

Presenter base class.

Parameters:

Name Type Description Default
name str

Identity key of the presenter. Passed as positional-only argument.

required
devices Mapping[str, Device]

Reference to the devices used in the presenter.

required
kwargs Any

Additional keyword arguments for presenter subclasses.

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

    Parameters
    ----------
    name : str
        Identity key of the presenter. Passed as positional-only argument.
    devices : Mapping[str, redsun.device.Device]
        Reference to the devices used in the presenter.
    kwargs : Any, optional
        Additional keyword arguments for presenter subclasses.
    """

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

Bases: Protocol

Presenter protocol class.

Attributes:

Name Type Description
name str

Identity key of the presenter.

devices Mapping[str, Device]

Reference to the devices used in the presenter.

Notes

Access to the virtual container is optional and should be acquired by implementing IsProvider or IsInjectable.

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

    Attributes
    ----------
    name : str
        Identity key of the presenter.
    devices : Mapping[str, redsun.device.Device]
        Reference to the devices used in the presenter.

    Notes
    -----
    Access to the virtual container is optional and should be acquired
    by implementing [`IsProvider`][redsun.virtual.IsProvider] or
    [`IsInjectable`][redsun.virtual.IsInjectable].
    """

    name: str
    devices: Mapping[str, Device]

Plan specification

Plan specification: inspect a plan's signature into a structured PlanSpec.

This module provides create_plan_spec, which inspects a Bluesky MsgGenerator function and returns a PlanSpec — a structured description of the plan's parameters that the view layer can use to automatically generate a parameter form.

The annotation dispatch system is table-driven: _ANN_HANDLER_MAP maps (predicate, handler) pairs that convert raw type annotations into ParamDescription fields (choices, device_proto, multiselect).

ParamKind

Bases: IntEnum

Public mirror of inspect._ParameterKind as a stable IntEnum.

Using a dedicated enum keeps the public API stable and allows use in match/case statements without importing private stdlib symbols.

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

    Using a dedicated enum keeps the public API stable and allows use in
    ``match``/``case`` statements without importing private stdlib symbols.
    """

    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 cannot be mapped to a widget.

Parameters:

Name Type Description Default
plan_name str

Name of the plan that contains the unresolvable parameter.

required
param_name str

Name of the parameter whose annotation could not be resolved.

required
annotation Any

The annotation that could not be resolved.

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

    Parameters
    ----------
    plan_name : str
        Name of the plan that contains the unresolvable parameter.
    param_name : str
        Name of the parameter whose annotation could not be resolved.
    annotation : Any
        The annotation that could not be resolved.
    """

    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"Supported types are: Literal, PDevice subtype, Sequence[PDevice], "
            f"Path, and magicgui-supported primitives (int, float, str, bool, …). "
            f"The plan will be skipped."
        )

create_plan_spec

create_plan_spec(
    plan: Callable[..., Generator[Any, Any, Any]],
    devices: Mapping[str, PDevice],
) -> 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) to inspect. Must be a generator function whose return annotation is a MsgGenerator.

required
devices Mapping[str, PDevice]

Registry of active devices; used to compute choices for parameters annotated with a PDevice subtype.

required

Returns:

Type Description
PlanSpec

Fully populated 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]).

RuntimeError

If an unexpected inspect.Parameter.kind is encountered.

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, PDevice],
) -> PlanSpec:
    """Inspect *plan* and return a ``PlanSpec`` with one ``ParamDescription`` per parameter.

    Parameters
    ----------
    plan : Callable[..., Any]
        The plan function (or bound method) to inspect.
        Must be a generator function whose return annotation is a ``MsgGenerator``.
    devices : Mapping[str, PDevice]
        Registry of active devices; used to compute ``choices`` for parameters
        annotated with a ``PDevice`` subtype.

    Returns
    -------
    PlanSpec
        Fully populated plan specification.

    Raises
    ------
    TypeError
        If *plan* is not a generator function or its return type is not a
        ``MsgGenerator`` (``Generator[Msg, Any, Any]``).
    RuntimeError
        If an unexpected ``inspect.Parameter.kind`` is encountered.
    """
    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 = get_type_hints(func_obj, include_extras=True)
    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):
        # Resolve the raw annotation, stripping Annotated[T, ...] → T
        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

        # Extract Action metadata (validated against the annotation)
        actions_meta = _extract_action_meta(param, ann)

        # Map inspect kind -> our ParamKind
        pkind = _PARAM_KIND_MAP.get(param.kind)
        if pkind is None:
            raise RuntimeError(f"Unexpected parameter kind: {param.kind!r}")

        # Dispatch annotation -> choices / device_proto / multiselect
        # (skip for Action parameters — they get no normal widget)
        if actions_meta is not None:
            fields = _FieldsFromAnnotation()
        else:
            fields = _dispatch_annotation(ann, pkind, devices)

        # Reject unresolvable required parameters
        # If dispatch produced no choices and no device_proto, the param
        # will fall through to the magicgui generic path at widget-creation
        # time. Probe that path now so we can fail fast here with a clear
        # error, rather than silently producing a broken LineEdit widget or
        # crashing later during plan execution.
        #
        # Parameters that are exempt from this check:
        # - Action params: never get a widget
        # - VAR_KEYWORD (**kwargs): no generic widget is ever built
        # - Params with a dispatch hit (choices set): already handled
        # - Params with a default: the default will be used if the widget
        #   can't be built, so the plan can still run
        # -----------------------------------------------------------------
        is_required = param.default is _empty
        needs_widget_probe = (
            actions_meta is None
            and is_required
            and pkind is not ParamKind.VAR_KEYWORD
            and fields.choices is None
        )
        if needs_widget_probe and not _is_magicgui_resolvable(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,
                hidden=False,
            )
        )

    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 (args, kwargs) for calling a plan, driven by a PlanSpec.

Parameters:

Name Type Description Default
spec PlanSpec

The plan specification.

required
values Mapping[str, Any]

Mapping of parameter names to their resolved values.

required

Returns:

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

Positional and keyword arguments ready to be splatted into the plan.

Notes
  • POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD → go into args in declaration order.
  • KEYWORD_ONLY → go into kwargs.
  • VAR_POSITIONAL (*args) → sequence is expanded into args.
  • VAR_KEYWORD (**kwargs) → mapping is 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 ``(args, kwargs)`` for calling a plan, driven by a ``PlanSpec``.

    Parameters
    ----------
    spec : PlanSpec
        The plan specification.
    values : Mapping[str, Any]
        Mapping of parameter names to their resolved values.

    Returns
    -------
    tuple[tuple[Any, ...], dict[str, Any]]
        Positional and keyword arguments ready to be splatted into the plan.

    Notes
    -----
    * ``POSITIONAL_ONLY`` and ``POSITIONAL_OR_KEYWORD`` → go into ``args`` in
      declaration order.
    * ``KEYWORD_ONLY`` → go into ``kwargs``.
    * ``VAR_POSITIONAL`` (``*args``) → sequence is expanded into ``args``.
    * ``VAR_KEYWORD`` (``**kwargs``) → mapping is 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, PDevice],
) -> dict[str, Any]

Resolve raw UI parameter values into plan-callable values.

Handles: * Action parameters — injected from metadata when absent from the UI. * Model-backed parameters — string labels are resolved to live PDevice instances via the devices registry. * Everything else — passed through unchanged.

Parameters:

Name Type Description Default
spec PlanSpec

The plan specification containing parameter metadata.

required
param_values Mapping[str, Any]

Raw parameter values from the UI.

required
devices Mapping[str, PDevice]

Active device registry.

required

Returns:

Type Description
dict[str, Any]

Resolved arguments ready 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, PDevice],
) -> dict[str, Any]:
    """Resolve raw UI parameter values into plan-callable values.

    Handles:
    * **Action parameters** — injected from metadata when absent from the UI.
    * **Model-backed parameters** — string labels are resolved to live
      ``PDevice`` instances via the ``devices`` registry.
    * **Everything else** — passed through unchanged.

    Parameters
    ----------
    spec : PlanSpec
        The plan specification containing parameter metadata.
    param_values : Mapping[str, Any]
        Raw parameter values from the UI.
    devices : Mapping[str, PDevice]
        Active device registry.

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

    # Inject Action metadata for parameters that have no UI widget
    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:
            # Coerce widget value (string, sequence, or set of strings) → list of labels
            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 a single plan parameter.

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

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

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

    annotation: Any
    """Unwrapped type annotation; `Annotated` metadata has been stripped."""

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

    choices: list[str] | None = None
    """String labels for selectable values; used for `Literal` and `PDevice`-backed parameters."""

    multiselect: bool = False
    """Whether the parameter allows multiple simultaneous selections (e.g. for `Sequence[PDevice]`)."""

    hidden: bool = False
    """Whether this parameter should be hidden from the UI (e.g. because it's only for metadata, not user input)."""

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

    device_proto: type[PDevice] | None = None
    """The `PDevice` protocol/class for model-backed parameters, if any; used for device look-up during argument resolution."""

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

name instance-attribute

name: str

Name of the parameter, as declared in the plan signature.

kind instance-attribute

kind: ParamKind

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

annotation instance-attribute

annotation: Any

Unwrapped type annotation; Annotated metadata has been stripped.

default instance-attribute

default: Any

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

choices class-attribute instance-attribute

choices: list[str] | None = None

String labels for selectable values; used for Literal and PDevice-backed parameters.

multiselect class-attribute instance-attribute

multiselect: bool = False

Whether the parameter allows multiple simultaneous selections (e.g. for Sequence[PDevice]).

hidden class-attribute instance-attribute

hidden: bool = False

Whether this parameter should be hidden from the UI (e.g. because it's only for metadata, not user input).

actions class-attribute instance-attribute

actions: Sequence[Action] | Action | None = None

Action metadata extracted from the parameter's default value, if any.

device_proto class-attribute instance-attribute

device_proto: type[PDevice] | None = None

The PDevice protocol/class for model-backed parameters, if any; used for device look-up during argument resolution.

has_default property

has_default: bool

Return True if this parameter carries a default value.

PlanSpec dataclass

Structured description of a plan's signature and type hints.

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

    name: str
    """Plan name (``__name__`` of the underlying callable)."""

    docs: str
    """Plan docstring, or a default message if no docstring is available."""

    parameters: list[ParamDescription]
    """Ordered list of parameter descriptions, one per plan parameter."""

    togglable: bool = False
    """Whether the plan runs as an infinite loop that can be stopped via a toggle button."""

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

name instance-attribute

name: str

Plan name (__name__ of the underlying callable).

docs instance-attribute

docs: str

Plan docstring, or a default message if no docstring is available.

parameters instance-attribute

parameters: list[ParamDescription]

Ordered list of parameter descriptions, one per plan parameter.

togglable class-attribute instance-attribute

togglable: bool = False

Whether the plan runs as an infinite loop that can be stopped via a toggle button.

pausable class-attribute instance-attribute

pausable: bool = False

Whether a running togglable plan can be paused and resumed.

Utilities

Utility predicates and helpers for plan parameter inspection.

These functions are used by create_plan_spec to classify parameter annotations and by resolve_arguments to resolve string device names into live PDevice instances.

get_choice_list

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

Filter a device registry to those that match a protocol and are in choices.

Parameters:

Name Type Description Default
devices Mapping[str, PDevice]

Mapping of device names to device instances.

required
proto type[P]

Protocol or class to match against via isinstance.

required
choices Sequence[str]

Subset of device names to consider.

required

Returns:

Type Description
list[P]

Device instances whose name is in choices and that satisfy proto.

Source code in src/redsun/presenter/utils.py
def get_choice_list(
    devices: Mapping[str, PDevice], proto: type[P], choices: Sequence[str]
) -> list[P]:
    """Filter a device registry to those that match a protocol and are in *choices*.

    Parameters
    ----------
    devices : Mapping[str, PDevice]
        Mapping of device names to device instances.
    proto : type[P]
        Protocol or class to match against via ``isinstance``.
    choices : Sequence[str]
        Subset of device names to consider.

    Returns
    -------
    list[P]
        Device instances whose name is in *choices* and that satisfy *proto*.
    """
    return [
        model
        for name, model in devices.items()
        if isinstance(model, proto) and name in choices
    ]

isdevice

isdevice(ann: Any) -> bool

Return True if ann is a class or Protocol that subclasses PDevice.

Operates on type annotations (the class itself), not on instances.

Source code in src/redsun/presenter/utils.py
def isdevice(ann: Any) -> bool:
    """Return True if *ann* is a class or Protocol that subclasses `PDevice`.

    Operates on type annotations (the class itself), not on instances.
    """
    return _is_pdevice_annotation(ann)

isdevicesequence

isdevicesequence(ann: Any) -> bool

Return True if ann is Sequence[T] where T is a PDevice 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 `PDevice` subtype."""
    if not issequence(ann):
        return False
    args = get_args(ann)
    return len(args) == 1 and _is_pdevice_annotation(args[0])

issequence

issequence(ann: Any) -> bool

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

Notes

str and bytes are sequences in the stdlib sense, but their annotations are not generic aliases (get_origin(str) is None), so they are naturally 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 in the stdlib sense, but their
    annotations are not generic aliases (``get_origin(str)`` is ``None``),
    so they are naturally excluded.
    """
    origin = get_origin(ann)
    if origin is None:
        return False
    try:
        return issubclass(origin, Sequence)
    except TypeError:
        return False