Skip to content

Container architecture

redsun leverages an architectural denominated to the Device-View-Presenter (DVP). This is semantically close to the definition of the Model-View-Presenter (MVP) architecture.

block-beta
  columns 6

  V1["View A"]:2
  V2["View B"]:2
  V3["View C"]:2
  VC["VirtualContainer"]:6
  P1["Presenter A"]:3
  P2["Presenter B"]:3
  D1["Device A"]:2
  D2["Device B"]:2
  D3["Device C"]:2

  style V1 fill:#4caf50,color:#fff,stroke:#388e3c
  style V2 fill:#4caf50,color:#fff,stroke:#388e3c
  style V3 fill:#4caf50,color:#fff,stroke:#388e3c
  style VC fill:#ffc107,color:#000,stroke:#f9a825
  style P1 fill:#f44336,color:#fff,stroke:#c62828
  style P2 fill:#f44336,color:#fff,stroke:#c62828
  style D1 fill:#2196f3,color:#fff,stroke:#1565c0
  style D2 fill:#2196f3,color:#fff,stroke:#1565c0
  style D3 fill:#2196f3,color:#fff,stroke:#1565c0

The key differences in respect to the MVP architecture are the following:

  • In MVP, the Model layer represents the data the application holds; think for example of a text editor: the content of the text is stored in this layer.
  • In contrast, the Device layer assumes the role of containing all objects interfacing with real hardware; it is both a semantic and pragmatic difference which, to avoid confusion, has been applied in the renaming of the architecture to make the distinction explicit.
  • Additionally, in the MVP pattern, Presenters and Views are tightly coupled between each other, making it difficult to have one without the other. In DVP, both layers are decoupled via a virtual container to follow an approach of dependency injection in order to maintain all the components separated, allowing to bring only the pieces you need to create an application fully compliant with your specifications.

Overview

At the core of Redsun is the AppContainer, which acts as the central registry and build system for all application components. Components are declared as class attributes and instantiated in a well-defined dependency order.

Build order — components are constructed in strict dependency sequence:

graph LR
    VC[VirtualContainer]
    Devices
    Presenters
    Views

    VC --> Devices
    Devices --> Presenters
    Presenters --> Views

Provider registration and dependency injection — once all components are built, any presenter or view implementing the relevant protocol participates in registration and injection:

graph LR
    VC[VirtualContainer]

    subgraph Presenters
        P1[Presenter A]
        P2[Presenter B]
    end

    subgraph Views
        V1[View A]
        V2[View B]
    end

    P1 -.->|IsProvider: register_providers| VC
    V1 -.->|IsProvider: register_providers| VC
    VC -.->|IsInjectable: inject_dependencies| P2
    VC -.->|IsInjectable: inject_dependencies| V2

The DVP pattern

redsun builds three types of components:

  • Devices: objects interfacing with real hardware components that implement Bluesky's device protocols via Device.
  • View: UI components that implement View to display data and capture user interactions.
  • Presenter: business logic components that implement Presenter, sitting between models and views, coordinating device operations and updating the UI through psygnal.

This separation ensures that hardware drivers, UI components, and business logic can be developed and tested independently.

Declarative containers

redsun operates on a bring-your-own components approach. Each component is intended to be developed separately and in isolation or as part of bundles of multiple components that can be dynamically assembled. In a declarative manner, this means importing the components explicitly and assigning them to a container.

Components are declared as class attributes using the layer-specific field specifiers: [device()][redsun.device], presenter(), and view(). Each accepts the component class as its first positional argument, followed by optional keyword arguments forwarded to the constructor.

When writing a container explicitly, you inherit from the frontend-specific subclass rather than the base AppContainer — for Qt applications that is QtAppContainer:

from redsun.containers import device, presenter, view
from redsun.qt import QtAppContainer


def my_app() -> None:
    class MyApp(QtAppContainer):
        motor = device(MyMotor, axis=["X", "Y"])
        ctrl = presenter(MyController, gain=1.0)
        ui = view(MyView)

    MyApp().run()

The class is defined inside a function so that the Qt imports and any heavy device imports are deferred until the application is actually launched.

The AppContainerMeta metaclass collects these declarations at class creation time. Because the class is passed directly to the field specifier, no annotation inspection is needed. This declarative approach allows the container to:

  • validate component types at class creation time;
  • inherit and override components from base classes;
  • merge configuration from YAML files with inline keyword arguments.

Component naming

Every component receives a name that is used as its key in the container's devices, presenters, or views dictionaries and passed as the first positional argument to the component constructor. The name is resolved with the following priority:

  1. alias — if an explicit alias is passed to device(), presenter(), or view(), that value is used regardless of everything else.
  2. attribute name — in the declarative flow, the Python attribute name becomes the component name when no alias is provided.
  3. YAML key — in the dynamic flow (from_config()), the top-level key in the devices/presenters/views section of the configuration file becomes the component name.

Examples in the declarative flow:

class MyApp(QtAppContainer):
    motor = device(MyMotor)                       # name → "motor"
    cam = device(MyCamera, alias="detector")      # name → "detector"

In the dynamic flow:

devices:
  iSCAT channel:           # name → "iSCAT channel"
    plugin_name: my-plugin
    plugin_id: my_detector

Configuration file support

Components can pull their keyword arguments from a YAML configuration file by passing config= to the class definition and from_config= to each field specifier call:

from redsun.containers import device, presenter, view
from redsun.qt import QtAppContainer


def my_app() -> None:
    class MyApp(QtAppContainer, config="app_config.yaml"):
        motor = device(MyMotor, from_config="motor")
        ctrl = presenter(MyController, from_config="ctrl")
        ui = view(MyView, from_config="ui")

    MyApp().run()

    # alternatively, you can first build and then run the app
    app = MyApp()
    app.build()
    app.run()

The configuration file provides base keyword arguments for each component. These can be selectively overridden by inline keyword arguments in the field specifier call, allowing the same container class to be reused across different hardware setups by swapping configuration files.

Build order

When build() is called, the container proceeds in three phases:

Phase 1 — construction:

  1. VirtualContainer — created and seeded with the application configuration.
  2. Devices — each receives its resolved name and keyword arguments.
  3. Presenters — each receives its resolved name and the full device dictionary.
  4. Views — each receives its resolved name.

Phase 2 — provider registration:

Any presenter or view implementing IsProvider calls register_providers() on the VirtualContainer. This is safe to run across both layers simultaneously because no injection occurs here.

Phase 3 — dependency injection:

Any presenter or view implementing IsInjectable calls inject_dependencies() on the VirtualContainer, consuming providers registered in phase 2.

Communication

Components communicate through the VirtualContainer, which serves as the single shared data exchange layer for the application. It combines two roles:

  • Signal registry: components can register their psygnal signals into the container via register_signals(), making them discoverable by other components without direct references to each other. Registered signals are accessible through the signals property.
  • Dependency injection: built on top of dependency_injector's DynamicContainer, it allows any presenter or view implementing IsProvider to register typed providers, and any presenter or view implementing IsInjectable to consume them. This enables components across both layers to share information without direct coupling.

The VirtualContainer is created during [build()][redsun.AppContainer.build] and is accessible via the virtual_container property after the container is built.

Two usage flows

redsun supports two distinct approaches for assembling an application, both producing the same result at runtime.

Explicit flow (developer-written containers)

The explicit flow is for plugin bundle authors who know exactly which components they need and which frontend they target. The container subclass, component classes, and frontend are all fixed at write time:

from redsun.containers import device, presenter, view
from redsun.qt import QtAppContainer

# these are user-developed classes
# that should reflect the structure
# provided by redsun for each layer
from my_package.device import MyMotor
from my_package.presenter import MyPresenter
from my_package.view import MyView


class MyApp(QtAppContainer, config="config.yaml"):
    motor = device(MyMotor, from_config="motor")
    ctrl = presenter(MyPresenter, from_config="ctrl")
    ui = view(MyView, from_config="ui")

MyApp().run()

Dynamic flow (configuration-driven)

The dynamic flow is for end users who point Redsun at a YAML configuration file. Plugins are discovered via entry points and the frontend is resolved from the frontend: key in the file — no Python code needs to be written:

from redsun import AppContainer

app = AppContainer.from_config("path/to/config.yaml")
app.run()

The YAML file drives everything:

schema_version: 1.0
session: "My Experiment"
frontend: "pyqt"

devices:
  motor:
    plugin_name: my-plugin
    plugin_id: my_motor

presenters:
  ...

views:
  ...

See the component system documentation for a full description of the dynamic flow.

Frontend support

Frontend is intended as the toolkit that deploys the functionalities to implement the Graphical User Interface (GUI).

Qt

QtAppContainer extends AppContainer with the full Qt lifecycle:

  1. Creates the QApplication instance.
  2. Calls build() to instantiate all components.
  3. Constructs the QtMainView main window and docks all views.
  4. Starts the psygnal signal queue bridge for thread-safe signal delivery.
  5. Shows the main window and enters the Qt event loop.

It is imported from the public redsun.qt namespace:

from redsun.qt import QtAppContainer

Both PyQt6 or PySide6 wrapped via qtpy are supported.

Other frontends

The future expectation is to provide support for other frontends (either desktop or web-based).

While the presenter and device layer are decoupled via the VirtualContainer, the View layer is tied to the frontend selection and plugins will have to implement each View according to the toolkit that the frontend provides. The hope is to find a way to minimize the code required to implement the UI and to simplify this approach across the board, regardless of the specified frontend.