Skip to content

How to migrate from 0.15

Release 0.16 removes the two things 0.15 deprecated, changes what a view of plan progress receives, moves some axes to another box of the positioner, removes two helpers the positioner no longer uses, changes what a session does when a service does not start, stops a held key from running its command again and again, moves the test fixtures for Qt into a plugin of their own, and reports a stopped plan on a signal of its own. This page gives each change with what to write instead. The changelog lists everything else the release changed.

A view's placement can't come from a property

The session reads a view's placement before it builds the view, so the placement has to be a plain class attribute or part of the declaration. A view class that answers placement from a property, and whose declaration gives none, is refused when the session reads its declarations: the view is left out and the log says it has no placement. Set placement as a class attribute when the place is always the same:

# 0.15
class MyView(QWidget):
    @property
    def placement(self) -> Placement:
        return Dock("left")


# now
class MyView(QWidget):
    placement: Placement = Dock("left")

When the place depends on the session, give it in the declaration instead, as How to place a view shows.

BUILD_STEPS is gone

redsun.session.BUILD_STEPS, the tuple of the steps a session's build goes through, is removed. Use the enumeration BuildStep: iterate it for the steps in order, and take len(BuildStep) for their count.

# 0.15
from redsun.session import BUILD_STEPS

# now
from redsun.session import BuildStep

Positioner boxes follow the device holding the axes

The positioner groups axes by the device that holds them directly. A device whose axes are its own children, such as a stage with the attributes x and y, keeps its box and its saved positions. A device whose axes sit one level deeper sees them move. This stage holds its axes in motors:

class MyStage(StandardReadable):
    def __init__(self, name: str = "") -> None:
        self.motors = DeviceMap({"x": MyAxis(), "y": MyAxis()})
        super().__init__(name=name)

In 0.15 its axes were rows of the box stage. Now they are rows of the box stage-motors, titled stage / motors, so positions saved under stage no longer match a box: the view leaves them out and logs a warning naming stage. Move the stage to each position again and save it. To keep the box stage instead, hold the axes as attributes of the stage, as in self.x = MyAxis(). An include list naming stage changes the same way, to stage-motors.

find_axes and walk_axes are gone

redsun.utils.devices.find_axes and walk_axes gathered every axis below one device. To find the axes below a device, walk it with walk_devices from ophyd-async and keep what is an Axis and not a signal:

# 0.15
axes = find_axes(stage)

# now
axes = {
    path: device
    for path, device in walk_devices(stage).items()
    if isinstance(device, Axis) and not isinstance(device, Signal)
}

Progress views receive the scopes

RunEngine.sig_progress carries the open progress scopes themselves, as PlanProgress objects, so ProgressState is gone. A scope gives its name, its parent scope, and in update the last WatcherUpdate of ophyd-async it reported, or None before the first. The update holds what the plan or the device reported and nothing more, so a view that showed fraction computes it from current, initial and target when the update has none. The scopes are the engine's own, and the engine replaces their update after the emission, so a view reads it once for each emission.

A view that passes the scopes on to the plan widget only changes its annotation to tuple[PlanProgress, ...]. A view that reads the values reads them from the scope's update:

# 0.15
@slot
def on_progress(self, scopes: tuple[ProgressState, ...]) -> None:
    for scope in scopes:
        print(scope.name, scope.parent, scope.current)


# now
@slot
def on_progress(self, scopes: tuple[PlanProgress, ...]) -> None:
    for scope in scopes:
        parent = scope.parent.name if scope.parent is not None else None
        current = scope.update.current if scope.update is not None else None
        print(scope.name, parent, current)

A watcher passed to PlanProgress.watch is now typed as the Watcher of ophyd-async, whose parameters can be passed by position or by name. A type checker therefore refuses a function that takes only **kwargs, or whose parameters are keyword-only; declare the parameters as plain named ones. A fraction reported alone reaches the watcher alone, with current, initial and target left None, and a device's status that reports no unit gives unit as None instead of "unit".

A strict session stops when a service does not start

A strict session now stops when any service fails to start, even one that no device names. In 0.15 such a session ran on, and stopped only if a device on the service could not be built. If the session declares a service it no longer uses, remove the service from the session file. If a service may be missing while you work, leave strict off for those runs:

MyApp({"strict": False}).run()

A prefix that defaults to None is optional

A device declared with service="x" whose prefix argument defaults to None is now built when the service does not start. The session leaves prefix out, so the device gets None, and the build summary lists it under Built without. In 0.15 the session skipped the device. To keep it skipped, give prefix no default:

# 0.15: skipped when the service does not start
class MyCamera(StandardReadable):
    def __init__(self, prefix: str | None = None, name: str = "") -> None: ...


# now, to keep it skipped
class MyCamera(StandardReadable):
    def __init__(self, prefix: str, name: str = "") -> None: ...

A held key runs its command once

A keyboard shortcut now runs its method once per press. In 0.15 a held key ran it again at the keyboard's repeat rate, so holding the key that starts a plan started it several times. To keep a key repeating, such as one that steps an axis, pass repeat=True to @shortcut:

# 0.15: repeats while held
class MyView(QWidget):
    @shortcut("Right", title="Step forward", scope="view")
    def step_forward(self) -> None: ...


# now
class MyView(QWidget):
    @shortcut("Right", title="Step forward", scope="view", repeat=True)
    def step_forward(self) -> None: ...

A stopped plan has a signal of its own

AcquisitionPresenter now reports a plan the user stopped on sig_plan_stopped, and keeps sig_plan_done for a plan that runs to its end. The built-in view shows the stop through its new slot, set_stopped. A session that pairs the two components gets the new link by itself. A session file that writes out the links under wiring adds the new one, or the view stays locked after a stop:

# 0.15
wiring:
  acquisition.sig_plan_done: acquisition_view.set_done

# now
wiring:
  acquisition.sig_plan_done: acquisition_view.set_done
  acquisition.sig_plan_stopped: acquisition_view.set_stopped

A component of your own that connects to sig_plan_done to learn that a run is over connects to sig_plan_stopped too.

The Qt test fixtures are a plugin of their own

keep_application_as_found, the fixture that fails a test leaving the application changed, moved from redsun.testing to redsun.testing.qt, and so did the setting that gives Qt's offscreen platform the system fonts on Windows. That module is a second pytest plugin, installed with the testing-qt extra, so a suite that tests no Qt components never imports Qt. A suite that tests Qt components changes its install line and its addopts:

# 0.15
uv add --dev "redsun[testing]" pytest-asyncio

# now
uv add --dev "redsun[testing-qt]" pytest-asyncio
# 0.15
addopts = "-p redsun.testing"

# now
addopts = "-p redsun.testing -p redsun.testing.qt"

The extra installs no Qt binding, so the suite keeps the one it installs today. redsun.testing.qt also defines a qapp fixture, and a suite that takes pytest-qt's or defines its own can keep it. How to test a plugin says what happens when pytest-qt is loaded too.