Skip to content

Virtual container

CallbackType module-attribute

CallbackType: TypeAlias = (
    Callable[[str, Document], None] | DocumentRouter
)

A document callback.

ProviderKey module-attribute

ProviderKey: TypeAlias = dip.Dependency[T]

A typed key identifying an object shared through the container.

SignalCache module-attribute

SignalCache: TypeAlias = dict[str, SignalInstance]

Signal instances registered by components.

SlotThread module-attribute

SlotThread: TypeAlias = (
    "Literal['main', 'current'] | Thread | None"
)

Thread a slot is delivered on, as accepted by psygnal.

RedSunConfig

Bases: TypedDict

Base configuration schema of a redsun application.

Attributes:

Name Type Description
schema_version Required[float]

Plugin schema version.

frontend Required[str]

Frontend toolkit identifier (e.g. "pyqt", "pyside").

session NotRequired[str]

Session display name, "redsun" if not given.

metadata NotRequired[dict[str, Any]]

Session metadata.

Source code in src/redsun/virtual/_config.py
class RedSunConfig(TypedDict, total=False):
    """Base configuration schema of a ``redsun`` application."""

    schema_version: Required[float]
    """Plugin schema version."""

    frontend: Required[str]
    """Frontend toolkit identifier (e.g. `"pyqt"`, `"pyside"`)."""

    session: NotRequired[str]
    """Session display name, `"redsun"` if not given."""

    metadata: NotRequired[dict[str, Any]]
    """Session metadata."""

VirtualContainer

Bases: DynamicContainer, Loggable

Signal bus, shared data and dependency injection of an application.

A DynamicContainer that is also the application's signal bus and data exchange.

Source code in src/redsun/virtual/_container.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
class VirtualContainer(dic.DynamicContainer, Loggable):
    """Signal bus, shared data and dependency injection of an application.

    A [`DynamicContainer`][dependency_injector.containers.DynamicContainer] that
    is also the application's signal bus and data exchange.
    """

    def __init__(self) -> None:
        super().__init__()
        # instance-scoped providers: class-level providers would be shared
        # across every container in the process, leaking config, signal,
        # and callback registrations between containers
        self._signals = dip.Factory(dict[str, SignalCache])
        self._callbacks = dip.Factory(dict[str, CallbackType])
        self._config = dip.Singleton(_FrozenConfig)
        self._components: dict[str, object] = {}
        self._links: list[tuple[SignalInstance, Callable[..., Any]]] = []
        self._connections: list[Connection] = []
        # the forwarding function is held because ophyd-async releases a
        # subscription by identity: clear_sub needs the object back
        self._subscriptions: list[
            tuple[SignalR[Any], Callable[[Any], None], SignalInstance]
        ] = []
        self._subscription_records: list[Subscription] = []
        # bindings are held here rather than through Dependency.override, which
        # mutates the key itself and would leak between containers in one process
        self._provided: dict[dip.Dependency[Any], Any] = {}

    @property
    def schema_version(self) -> float:
        """The configuration's plugin schema version."""
        return self._config().schema_version

    @property
    def frontend(self) -> str:
        """The configuration's frontend toolkit."""
        return self._config().frontend

    @property
    def session(self) -> str:
        """The configuration's session display name."""
        return self._config().session

    @property
    def metadata(self) -> dict[str, object]:
        """The configuration's session metadata."""
        return self._config().metadata

    def _set_configuration(self, config: RedSunConfig) -> None:
        """Set the application configuration, at build time.

        Parameters
        ----------
        config : RedSunConfig
            The application configuration to set.
        """
        self._config.set_kwargs(
            schema_version=config["schema_version"],
            frontend=config["frontend"],
            session=config.get("session", "redsun"),
            metadata=config.get("metadata", {}),
        )

    def provide(self, key: ProviderKey[T], value: T) -> None:
        """Bind *value* to *key* for this container.

        Parameters
        ----------
        key : ProviderKey[T]
            The key consumers resolve.
        value : T
            The object to share. Rebinding an already bound key replaces it.

        Raises
        ------
        TypeError
            If *value* is not an instance of the key's ``instance_of``.
        """
        if not isinstance(value, key.instance_of):
            raise TypeError(
                f"{value!r} is not an instance of "
                f"{key.instance_of.__name__}, required by {key!r}"
            )
        self._provided[key] = value

    def require(self, key: ProviderKey[T]) -> T:
        """Resolve *key*, which must be bound.

        Parameters
        ----------
        key : ProviderKey[T]
            The key to resolve.

        Returns
        -------
        T
            The bound value.

        Raises
        ------
        KeyError
            If nothing bound *key*. Keys are bound in ``register_providers``,
            so one read earlier is unbound even if its component exists.
        """
        try:
            return cast("T", self._provided[key])
        except KeyError:
            raise KeyError(
                f"nothing provided {key!r}; the component that owns it is "
                "either absent from this application or has not run "
                "'register_providers' yet"
            ) from None

    def try_require(self, key: ProviderKey[T]) -> T | None:
        """Resolve *key*, or return ``None`` if nothing bound it.

        Parameters
        ----------
        key : ProviderKey[T]
            The key to resolve.

        Returns
        -------
        T | None
            The bound value, or ``None`` if the application lacks this optional
            collaborator.
        """
        return cast("T | None", self._provided.get(key))

    def register_signals(
        self, owner: HasName, name: str | None = None, only: Iterable[str] | None = None
    ) -> None:
        """Cache the signals *owner* declares.

        Parameters
        ----------
        owner : HasName
            The component whose class signals are cached.
        name : str | None
            Registry key. Defaults to ``owner.name``.
        only : Iterable[str] | None
            Signal names to cache. Defaults to every
            [`Signal`][psygnal.Signal] declared on the class.
        """
        owner_class = type(owner)
        if name is not None:
            cache_entry = name
        else:
            cache_entry = owner.name

        if only is None:
            only = [
                attr
                for attr in dir(owner_class)
                if isinstance(getattr(owner_class, attr, None), Signal)
            ]

        batch: dict[str, SignalInstance] = {}
        for signal_name in only:
            signal_descriptor = getattr(owner_class, signal_name, None)
            if isinstance(signal_descriptor, Signal):
                signal_instance = getattr(owner, signal_name)
                batch[signal_name] = signal_instance
        if batch:
            self._signals.add_kwargs(**{cache_entry: batch})

    @staticmethod
    def _validate_callback(callback: object) -> CallbackType:
        """Return *callback* unchanged if it can be called as ``(name, doc)``.

        Parameters
        ----------
        callback : object
            The object to validate.

        Returns
        -------
        CallbackType
            The validated callback.

        Raises
        ------
        TypeError
            If *callback* is not callable as ``(str, Document)``.
        """
        if isinstance(callback, DocumentRouter):
            return callback

        if not callable(callback):
            raise TypeError(
                f"{callback!r} is not callable. "
                "A callback must be a DocumentRouter subclass instance or a "
                "callable accepting (str, Document) arguments."
            )

        try:
            inspect.signature(callback.__call__).bind(None, None)
        except TypeError as e:
            raise TypeError(
                f"{callback!r} is callable but its signature is not compatible "
                "with the expected (str, Document) callback interface."
            ) from e

        return callback

    def register_callbacks(
        self,
        owner: HasName,
        name: str | None = None,
        callback_map: dict[str, CallbackType] | None = None,
    ) -> None:
        """Register one or more document callbacks.

        A callback is an ``event_model.DocumentRouter`` or any object callable
        as ``(name, doc)``.

        Parameters
        ----------
        owner : HasName
            The component registering callbacks, and the callback itself when
            *callback_map* is ``None``.
        name : str | None
            Registry key for *owner*. Defaults to ``owner.name``; ignored when
            *callback_map* is given.
        callback_map : dict[str, CallbackType] | None
            Several callbacks from one owner, each registered under its own
            key. *owner* is then not registered itself.

        Raises
        ------
        TypeError
            If a callback is not callable as ``(str, Document)``.
        """
        if callback_map is not None:
            for key, callback in callback_map.items():
                self._callbacks.add_kwargs(**{key: self._validate_callback(callback)})
            return

        cache_entry = name if name is not None else owner.name
        self._callbacks.add_kwargs(**{cache_entry: self._validate_callback(owner)})

    @property
    def callbacks(self) -> dict[str, CallbackType]:
        """The registered document callbacks."""
        return self._callbacks()

    @property
    def signals(self) -> dict[str, SignalCache]:
        """The registered signals."""
        return self._signals()

    def _set_components(self, components: Mapping[str, object]) -> None:
        """Record the built components' names, for the wiring report."""
        self._components = dict(components)

    def _label(self, component: object | None) -> str:
        # searched by identity rather than read from an id-keyed index: an id
        # identifies an object only while it is alive, and CPython hands a
        # released component's id to the next object of that size
        if component is None:
            return "<unknown>"
        for name, candidate in self._components.items():
            if candidate is component:
                return name
        return type(component).__name__

    def connect(
        self,
        signal: SignalInstance,
        slot: Callable[..., Any],
        *,
        thread: SlotThread = None,
    ) -> Connection:
        """Connect a signal to a slot and record the link.

        Parameters
        ----------
        signal : SignalInstance
            The emitting signal.
        slot : Callable[..., Any]
            A bound method marked with [`slot`][redsun.virtual.slot]. May be a
            coroutine function.
        thread : SlotThread
            Thread the slot runs on. Defaults to the slot's affinity, then its
            class's.

        Returns
        -------
        Connection
            The recorded link.

        Raises
        ------
        WiringError
            If *slot* is not marked, or ``psygnal`` rejects the signatures.
        """
        consumer, thread = _resolve_slot(slot, thread)

        link = Connection(
            publisher=self._label(_owner_of(signal)),
            publisher_port=signal.name or "<anonymous>",
            consumer=self._label(consumer),
            consumer_port=port_name(slot),
            thread=thread,
        )
        try:
            signal.connect(slot, thread=thread)
        except (TypeError, ValueError) as e:
            raise WiringError(f"cannot connect {link}: {e}") from e

        self._links.append((signal, slot))
        self._connections.append(link)
        self.logger.debug(f"Connected {link}")
        return link

    def subscribe(
        self,
        signal: SignalR[Any],
        slot: Callable[..., Any],
        *,
        thread: SlotThread = None,
    ) -> Subscription:
        """Subscribe a slot to an ``ophyd-async`` device signal and record it.

        Readings pass through a ``psygnal`` signal, so *thread* works as in
        `connect`. It is the only way a device signal reaches a slot with a
        thread affinity: ``ophyd-async`` calls subscribers on whichever thread
        produced the reading.

        Parameters
        ----------
        signal : SignalR[Any]
            The device signal to observe.
        slot : Callable[..., Any]
            A bound method marked with [`slot`][redsun.virtual.slot], called
            with the reading dictionary.
        thread : SlotThread
            Thread the slot runs on. Defaults to the slot's affinity, then its
            class's.

        Returns
        -------
        Subscription
            The recorded subscription.

        Raises
        ------
        WiringError
            If *slot* is not marked.
        """
        consumer, thread = _resolve_slot(slot, thread)

        relay = SignalInstance((object,), name=signal.name)
        relay.connect(slot, thread=thread)

        def forward(reading: Any) -> None:
            relay.emit(reading)

        record = Subscription(
            source=signal.name,
            consumer=self._label(consumer),
            consumer_port=port_name(slot),
            thread=thread,
        )

        async def attach() -> None:
            signal.subscribe_reading(forward)

        # ophyd-async requires a running loop to subscribe, and callers run on
        # the main thread during the build
        run_coro(attach())
        self._subscriptions.append((signal, forward, relay))
        self._subscription_records.append(record)
        self.logger.debug(f"Subscribed {record}")
        return record

    @property
    def subscriptions(self) -> list[Subscription]:
        """The device-signal subscriptions made through this container."""
        return list(self._subscription_records)

    def connect_paths(
        self, source: str, target: str, *, thread: SlotThread = None
    ) -> Connection:
        """Connect two ports addressed as ``component.port``.

        The string form of `connect`, used by a configuration file's ``wiring``
        section. A signal port is the signal's attribute name, or its member
        name in a signal group; a slot port is the name the slot declares.

        Parameters
        ----------
        source : str
            Path of the emitting signal.
        target : str
            Path of the consuming slot.
        thread : SlotThread
            Thread the slot runs on, overriding the slot and its class.

        Returns
        -------
        Connection
            The recorded link.

        Raises
        ------
        WiringError
            If a path is malformed or names a port the component lacks; the
            message lists the existing ones.
        ComponentNotBuilt
            If a path names a component that is not there.
        """
        signal = self._resolve_port(source, "signal")
        slot = self._resolve_port(target, "slot")
        return self.connect(
            cast("SignalInstance", signal),
            cast("Callable[..., Any]", slot),
            thread=thread,
        )

    def _resolve_port(self, path: str, kind: str) -> object:
        """Look up the signal or slot a ``component.port`` path names."""
        component_name, _, port = path.partition(".")
        if not component_name or not port or "." in port:
            raise WiringError(f"{path!r} is not a port path; expected 'component.port'")
        component = self._components.get(component_name)
        if component is None:
            known = ", ".join(sorted(self._components)) or "none"
            raise ComponentNotBuilt(
                component_name,
                f"{path!r} names component {component_name!r}, which was not "
                f"built. Built: {known}",
            )
        surface = ports(component)
        available = surface.signals if kind == "signal" else surface.slots
        if port not in available:
            known = ", ".join(sorted(available)) or "none"
            raise WiringError(
                f"{component_name!r} exposes no {kind} named {port!r}. "
                f"Its {kind} ports: {known}"
            )
        return available[port]

    @property
    def connections(self) -> list[Connection]:
        """The links established so far."""
        return list(self._connections)

    @property
    def unconnected(self) -> Unconnected:
        """Ports of the built components that no connection reaches.

        What components offer minus `connections` and `subscriptions`. A
        forgotten connection shows nowhere else, since an unnamed port cannot
        fail.

        Returns
        -------
        Unconnected
            The unreached signal and slot paths, as ``component.port``.

        Raises
        ------
        WiringError
            If a component exposes two signals under one port name.
        """
        used_signals = {(c.publisher, c.publisher_port) for c in self._connections}
        used_slots = {(c.consumer, c.consumer_port) for c in self._connections}
        used_slots |= {
            (s.consumer, s.consumer_port) for s in self._subscription_records
        }

        signals: list[str] = []
        slots: list[str] = []
        for name, component in self._components.items():
            surface = ports(component)
            signals += [
                f"{name}.{port}"
                for port in surface.signals
                if (name, port) not in used_signals
            ]
            slots += [
                f"{name}.{port}"
                for port in surface.slots
                if (name, port) not in used_slots
            ]
        return Unconnected(signals=signals, slots=slots)

    def disconnect_all(self) -> None:
        """Undo every connection and subscription made through this container."""
        for signal, slot in self._links:
            signal.disconnect(slot, missing_ok=True)
        self._links.clear()
        self._connections.clear()

        async def release(signal: SignalR[Any], forward: Callable[[Any], None]) -> None:
            signal.clear_sub(forward)

        for device_signal, forward, relay in self._subscriptions:
            run_coro(release(device_signal, forward))
            relay.disconnect()
        self._subscriptions.clear()
        self._subscription_records.clear()

    def _clear_components(self) -> None:
        """Forget the built components, after disconnecting them."""
        self._components = {}

schema_version property

schema_version: float

The configuration's plugin schema version.

frontend property

frontend: str

The configuration's frontend toolkit.

session property

session: str

The configuration's session display name.

metadata property

metadata: dict[str, object]

The configuration's session metadata.

callbacks property

callbacks: dict[str, CallbackType]

The registered document callbacks.

signals property

signals: dict[str, SignalCache]

The registered signals.

subscriptions property

subscriptions: list[Subscription]

The device-signal subscriptions made through this container.

connections property

connections: list[Connection]

The links established so far.

unconnected property

unconnected: Unconnected

Ports of the built components that no connection reaches.

What components offer minus connections and subscriptions. A forgotten connection shows nowhere else, since an unnamed port cannot fail.

Returns:

Type Description
Unconnected

The unreached signal and slot paths, as component.port.

Raises:

Type Description
WiringError

If a component exposes two signals under one port name.

provide

provide(key: ProviderKey[T], value: T) -> None

Bind value to key for this container.

Parameters:

Name Type Description Default
key ProviderKey[T]

The key consumers resolve.

required
value T

The object to share. Rebinding an already bound key replaces it.

required

Raises:

Type Description
TypeError

If value is not an instance of the key's instance_of.

Source code in src/redsun/virtual/_container.py
def provide(self, key: ProviderKey[T], value: T) -> None:
    """Bind *value* to *key* for this container.

    Parameters
    ----------
    key : ProviderKey[T]
        The key consumers resolve.
    value : T
        The object to share. Rebinding an already bound key replaces it.

    Raises
    ------
    TypeError
        If *value* is not an instance of the key's ``instance_of``.
    """
    if not isinstance(value, key.instance_of):
        raise TypeError(
            f"{value!r} is not an instance of "
            f"{key.instance_of.__name__}, required by {key!r}"
        )
    self._provided[key] = value

require

require(key: ProviderKey[T]) -> T

Resolve key, which must be bound.

Parameters:

Name Type Description Default
key ProviderKey[T]

The key to resolve.

required

Returns:

Type Description
T

The bound value.

Raises:

Type Description
KeyError

If nothing bound key. Keys are bound in register_providers, so one read earlier is unbound even if its component exists.

Source code in src/redsun/virtual/_container.py
def require(self, key: ProviderKey[T]) -> T:
    """Resolve *key*, which must be bound.

    Parameters
    ----------
    key : ProviderKey[T]
        The key to resolve.

    Returns
    -------
    T
        The bound value.

    Raises
    ------
    KeyError
        If nothing bound *key*. Keys are bound in ``register_providers``,
        so one read earlier is unbound even if its component exists.
    """
    try:
        return cast("T", self._provided[key])
    except KeyError:
        raise KeyError(
            f"nothing provided {key!r}; the component that owns it is "
            "either absent from this application or has not run "
            "'register_providers' yet"
        ) from None

try_require

try_require(key: ProviderKey[T]) -> T | None

Resolve key, or return None if nothing bound it.

Parameters:

Name Type Description Default
key ProviderKey[T]

The key to resolve.

required

Returns:

Type Description
T | None

The bound value, or None if the application lacks this optional collaborator.

Source code in src/redsun/virtual/_container.py
def try_require(self, key: ProviderKey[T]) -> T | None:
    """Resolve *key*, or return ``None`` if nothing bound it.

    Parameters
    ----------
    key : ProviderKey[T]
        The key to resolve.

    Returns
    -------
    T | None
        The bound value, or ``None`` if the application lacks this optional
        collaborator.
    """
    return cast("T | None", self._provided.get(key))

register_signals

register_signals(
    owner: HasName,
    name: str | None = None,
    only: Iterable[str] | None = None,
) -> None

Cache the signals owner declares.

Parameters:

Name Type Description Default
owner HasName

The component whose class signals are cached.

required
name str | None

Registry key. Defaults to owner.name.

None
only Iterable[str] | None

Signal names to cache. Defaults to every Signal declared on the class.

None
Source code in src/redsun/virtual/_container.py
def register_signals(
    self, owner: HasName, name: str | None = None, only: Iterable[str] | None = None
) -> None:
    """Cache the signals *owner* declares.

    Parameters
    ----------
    owner : HasName
        The component whose class signals are cached.
    name : str | None
        Registry key. Defaults to ``owner.name``.
    only : Iterable[str] | None
        Signal names to cache. Defaults to every
        [`Signal`][psygnal.Signal] declared on the class.
    """
    owner_class = type(owner)
    if name is not None:
        cache_entry = name
    else:
        cache_entry = owner.name

    if only is None:
        only = [
            attr
            for attr in dir(owner_class)
            if isinstance(getattr(owner_class, attr, None), Signal)
        ]

    batch: dict[str, SignalInstance] = {}
    for signal_name in only:
        signal_descriptor = getattr(owner_class, signal_name, None)
        if isinstance(signal_descriptor, Signal):
            signal_instance = getattr(owner, signal_name)
            batch[signal_name] = signal_instance
    if batch:
        self._signals.add_kwargs(**{cache_entry: batch})

register_callbacks

register_callbacks(
    owner: HasName,
    name: str | None = None,
    callback_map: dict[str, CallbackType] | None = None,
) -> None

Register one or more document callbacks.

A callback is an event_model.DocumentRouter or any object callable as (name, doc).

Parameters:

Name Type Description Default
owner HasName

The component registering callbacks, and the callback itself when callback_map is None.

required
name str | None

Registry key for owner. Defaults to owner.name; ignored when callback_map is given.

None
callback_map dict[str, CallbackType] | None

Several callbacks from one owner, each registered under its own key. owner is then not registered itself.

None

Raises:

Type Description
TypeError

If a callback is not callable as (str, Document).

Source code in src/redsun/virtual/_container.py
def register_callbacks(
    self,
    owner: HasName,
    name: str | None = None,
    callback_map: dict[str, CallbackType] | None = None,
) -> None:
    """Register one or more document callbacks.

    A callback is an ``event_model.DocumentRouter`` or any object callable
    as ``(name, doc)``.

    Parameters
    ----------
    owner : HasName
        The component registering callbacks, and the callback itself when
        *callback_map* is ``None``.
    name : str | None
        Registry key for *owner*. Defaults to ``owner.name``; ignored when
        *callback_map* is given.
    callback_map : dict[str, CallbackType] | None
        Several callbacks from one owner, each registered under its own
        key. *owner* is then not registered itself.

    Raises
    ------
    TypeError
        If a callback is not callable as ``(str, Document)``.
    """
    if callback_map is not None:
        for key, callback in callback_map.items():
            self._callbacks.add_kwargs(**{key: self._validate_callback(callback)})
        return

    cache_entry = name if name is not None else owner.name
    self._callbacks.add_kwargs(**{cache_entry: self._validate_callback(owner)})

connect

connect(
    signal: SignalInstance,
    slot: Callable[..., Any],
    *,
    thread: SlotThread = None,
) -> Connection

Connect a signal to a slot and record the link.

Parameters:

Name Type Description Default
signal SignalInstance

The emitting signal.

required
slot Callable[..., Any]

A bound method marked with slot. May be a coroutine function.

required
thread SlotThread

Thread the slot runs on. Defaults to the slot's affinity, then its class's.

None

Returns:

Type Description
Connection

The recorded link.

Raises:

Type Description
WiringError

If slot is not marked, or psygnal rejects the signatures.

Source code in src/redsun/virtual/_container.py
def connect(
    self,
    signal: SignalInstance,
    slot: Callable[..., Any],
    *,
    thread: SlotThread = None,
) -> Connection:
    """Connect a signal to a slot and record the link.

    Parameters
    ----------
    signal : SignalInstance
        The emitting signal.
    slot : Callable[..., Any]
        A bound method marked with [`slot`][redsun.virtual.slot]. May be a
        coroutine function.
    thread : SlotThread
        Thread the slot runs on. Defaults to the slot's affinity, then its
        class's.

    Returns
    -------
    Connection
        The recorded link.

    Raises
    ------
    WiringError
        If *slot* is not marked, or ``psygnal`` rejects the signatures.
    """
    consumer, thread = _resolve_slot(slot, thread)

    link = Connection(
        publisher=self._label(_owner_of(signal)),
        publisher_port=signal.name or "<anonymous>",
        consumer=self._label(consumer),
        consumer_port=port_name(slot),
        thread=thread,
    )
    try:
        signal.connect(slot, thread=thread)
    except (TypeError, ValueError) as e:
        raise WiringError(f"cannot connect {link}: {e}") from e

    self._links.append((signal, slot))
    self._connections.append(link)
    self.logger.debug(f"Connected {link}")
    return link

subscribe

subscribe(
    signal: SignalR[Any],
    slot: Callable[..., Any],
    *,
    thread: SlotThread = None,
) -> Subscription

Subscribe a slot to an ophyd-async device signal and record it.

Readings pass through a psygnal signal, so thread works as in connect. It is the only way a device signal reaches a slot with a thread affinity: ophyd-async calls subscribers on whichever thread produced the reading.

Parameters:

Name Type Description Default
signal SignalR[Any]

The device signal to observe.

required
slot Callable[..., Any]

A bound method marked with slot, called with the reading dictionary.

required
thread SlotThread

Thread the slot runs on. Defaults to the slot's affinity, then its class's.

None

Returns:

Type Description
Subscription

The recorded subscription.

Raises:

Type Description
WiringError

If slot is not marked.

Source code in src/redsun/virtual/_container.py
def subscribe(
    self,
    signal: SignalR[Any],
    slot: Callable[..., Any],
    *,
    thread: SlotThread = None,
) -> Subscription:
    """Subscribe a slot to an ``ophyd-async`` device signal and record it.

    Readings pass through a ``psygnal`` signal, so *thread* works as in
    `connect`. It is the only way a device signal reaches a slot with a
    thread affinity: ``ophyd-async`` calls subscribers on whichever thread
    produced the reading.

    Parameters
    ----------
    signal : SignalR[Any]
        The device signal to observe.
    slot : Callable[..., Any]
        A bound method marked with [`slot`][redsun.virtual.slot], called
        with the reading dictionary.
    thread : SlotThread
        Thread the slot runs on. Defaults to the slot's affinity, then its
        class's.

    Returns
    -------
    Subscription
        The recorded subscription.

    Raises
    ------
    WiringError
        If *slot* is not marked.
    """
    consumer, thread = _resolve_slot(slot, thread)

    relay = SignalInstance((object,), name=signal.name)
    relay.connect(slot, thread=thread)

    def forward(reading: Any) -> None:
        relay.emit(reading)

    record = Subscription(
        source=signal.name,
        consumer=self._label(consumer),
        consumer_port=port_name(slot),
        thread=thread,
    )

    async def attach() -> None:
        signal.subscribe_reading(forward)

    # ophyd-async requires a running loop to subscribe, and callers run on
    # the main thread during the build
    run_coro(attach())
    self._subscriptions.append((signal, forward, relay))
    self._subscription_records.append(record)
    self.logger.debug(f"Subscribed {record}")
    return record

connect_paths

connect_paths(
    source: str, target: str, *, thread: SlotThread = None
) -> Connection

Connect two ports addressed as component.port.

The string form of connect, used by a configuration file's wiring section. A signal port is the signal's attribute name, or its member name in a signal group; a slot port is the name the slot declares.

Parameters:

Name Type Description Default
source str

Path of the emitting signal.

required
target str

Path of the consuming slot.

required
thread SlotThread

Thread the slot runs on, overriding the slot and its class.

None

Returns:

Type Description
Connection

The recorded link.

Raises:

Type Description
WiringError

If a path is malformed or names a port the component lacks; the message lists the existing ones.

ComponentNotBuilt

If a path names a component that is not there.

Source code in src/redsun/virtual/_container.py
def connect_paths(
    self, source: str, target: str, *, thread: SlotThread = None
) -> Connection:
    """Connect two ports addressed as ``component.port``.

    The string form of `connect`, used by a configuration file's ``wiring``
    section. A signal port is the signal's attribute name, or its member
    name in a signal group; a slot port is the name the slot declares.

    Parameters
    ----------
    source : str
        Path of the emitting signal.
    target : str
        Path of the consuming slot.
    thread : SlotThread
        Thread the slot runs on, overriding the slot and its class.

    Returns
    -------
    Connection
        The recorded link.

    Raises
    ------
    WiringError
        If a path is malformed or names a port the component lacks; the
        message lists the existing ones.
    ComponentNotBuilt
        If a path names a component that is not there.
    """
    signal = self._resolve_port(source, "signal")
    slot = self._resolve_port(target, "slot")
    return self.connect(
        cast("SignalInstance", signal),
        cast("Callable[..., Any]", slot),
        thread=thread,
    )

disconnect_all

disconnect_all() -> None

Undo every connection and subscription made through this container.

Source code in src/redsun/virtual/_container.py
def disconnect_all(self) -> None:
    """Undo every connection and subscription made through this container."""
    for signal, slot in self._links:
        signal.disconnect(slot, missing_ok=True)
    self._links.clear()
    self._connections.clear()

    async def release(signal: SignalR[Any], forward: Callable[[Any], None]) -> None:
        signal.clear_sub(forward)

    for device_signal, forward, relay in self._subscriptions:
        run_coro(release(device_signal, forward))
        relay.disconnect()
    self._subscriptions.clear()
    self._subscription_records.clear()

HasShutdown

Bases: Protocol

A class that shuts down synchronously.

Source code in src/redsun/virtual/_protocols.py
@runtime_checkable
class HasShutdown(Protocol):  # pragma: no cover
    """A class that shuts down synchronously."""

    @abstractmethod
    def shutdown(self) -> None:
        """Clean up. Called on presenters and container hooks."""
        ...

shutdown abstractmethod

shutdown() -> None

Clean up. Called on presenters and container hooks.

Source code in src/redsun/virtual/_protocols.py
@abstractmethod
def shutdown(self) -> None:
    """Clean up. Called on presenters and container hooks."""
    ...

IsInjectable

Bases: Protocol

A class receiving dependencies from the virtual container.

Source code in src/redsun/virtual/_protocols.py
@runtime_checkable
class IsInjectable(Protocol):  # pragma: no cover
    """A class receiving dependencies from the virtual container."""

    @abstractmethod
    def inject_dependencies(self, container: VirtualContainer) -> None:
        """Inject dependencies from the container."""
        ...

inject_dependencies abstractmethod

inject_dependencies(container: VirtualContainer) -> None

Inject dependencies from the container.

Source code in src/redsun/virtual/_protocols.py
@abstractmethod
def inject_dependencies(self, container: VirtualContainer) -> None:
    """Inject dependencies from the container."""
    ...

IsProvider

Bases: Protocol

A class providing dependencies to the virtual container.

Source code in src/redsun/virtual/_protocols.py
@runtime_checkable
class IsProvider(Protocol):  # pragma: no cover
    """A class providing dependencies to the virtual container."""

    @abstractmethod
    def register_providers(self, container: VirtualContainer) -> None:
        """Register providers in the virtual container."""
        ...

register_providers abstractmethod

register_providers(container: VirtualContainer) -> None

Register providers in the virtual container.

Source code in src/redsun/virtual/_protocols.py
@abstractmethod
def register_providers(self, container: VirtualContainer) -> None:
    """Register providers in the virtual container."""
    ...

ComponentNotBuilt

Bases: WiringError

Raised when a port path names a component that is not there.

component is the name in the path, so a caller knowing which components failed to build can tell those apart from a name never declared.

Source code in src/redsun/virtual/_wiring.py
class ComponentNotBuilt(WiringError):
    """Raised when a port path names a component that is not there.

    ``component`` is the name in the path, so a caller knowing which components
    failed to build can tell those apart from a name never declared.
    """

    def __init__(self, component: str, message: str) -> None:
        super().__init__(message)
        self.component = component

Connection dataclass

A recorded link between a signal and a slot.

Attributes:

Name Type Description
publisher str
publisher_port str
consumer str
consumer_port str
thread SlotThread
Source code in src/redsun/virtual/_wiring.py
@dataclass(frozen=True, kw_only=True, slots=True)
class Connection:
    """A recorded link between a signal and a slot."""

    publisher: str
    publisher_port: str
    consumer: str
    consumer_port: str
    thread: SlotThread = None

    def __str__(self) -> str:
        thread = f"  [thread={self.thread}]" if self.thread else ""
        return (
            f"{self.publisher}.{self.publisher_port} -> "
            f"{self.consumer}.{self.consumer_port}{thread}"
        )

Ports dataclass

The connectable surface of a component.

Attributes:

Name Type Description
signals dict[str, SignalInstance]

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

slots dict[str, Callable[..., Any]]

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

Source code in src/redsun/virtual/_wiring.py
@dataclass(frozen=True, slots=True)
class Ports:
    """The connectable surface of a component."""

    signals: dict[str, SignalInstance] = field(default_factory=dict)
    slots: dict[str, Callable[..., Any]] = field(default_factory=dict)

Subscription dataclass

A recorded subscription to a device signal.

Attributes:

Name Type Description
source str
consumer str
consumer_port str
thread SlotThread
Source code in src/redsun/virtual/_wiring.py
@dataclass(frozen=True, kw_only=True, slots=True)
class Subscription:
    """A recorded subscription to a device signal."""

    source: str
    consumer: str
    consumer_port: str
    thread: SlotThread = None

    def __str__(self) -> str:
        thread = f"  [thread={self.thread}]" if self.thread else ""
        return f"{self.source} ~> {self.consumer}.{self.consumer_port}{thread}"

Unconnected dataclass

Ports of the built components that no connection reaches.

Each entry is a component.port path. A listed signal emits to nothing; a listed slot is never called.

Attributes:

Name Type Description
signals list[str]

Built-in mutable sequence.

If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.

slots list[str]

Built-in mutable sequence.

If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.

Source code in src/redsun/virtual/_wiring.py
@dataclass(frozen=True, kw_only=True, slots=True)
class Unconnected:
    """Ports of the built components that no connection reaches.

    Each entry is a ``component.port`` path. A listed signal emits to nothing; a
    listed slot is never called.
    """

    signals: list[str] = field(default_factory=list)
    slots: list[str] = field(default_factory=list)

    def __bool__(self) -> bool:
        return bool(self.signals or self.slots)

    def __str__(self) -> str:
        if not self:
            return "every port is connected"
        lines = [f"{path} -> nothing" for path in self.signals]
        lines += [f"nothing -> {path}" for path in self.slots]
        return "\n".join(lines)

WiringError

Bases: RuntimeError

Raised when a connection between two components cannot be made.

Source code in src/redsun/virtual/_wiring.py
class WiringError(RuntimeError):
    """Raised when a connection between two components cannot be made."""

ports

ports(component: object) -> Ports

Return the signals and slots component exposes, by port name.

A signal is a public Signal attribute, or a member of a SignalGroup the component holds, named by its member name. A slot is a method marked with slot.

Parameters:

Name Type Description Default
component object

The built component to inspect.

required

Returns:

Type Description
Ports

Its signals and slots, keyed by port name.

Raises:

Type Description
WiringError

If two signals have the same port name.

Source code in src/redsun/virtual/_wiring.py
def ports(component: object) -> Ports:
    """Return the signals and slots *component* exposes, by port name.

    A signal is a public [`Signal`][psygnal.Signal] attribute, or a member of a
    [`SignalGroup`][psygnal.SignalGroup] the component holds, named by its
    member name. A slot is a method marked with `slot`.

    Parameters
    ----------
    component : object
        The built component to inspect.

    Returns
    -------
    Ports
        Its signals and slots, keyed by port name.

    Raises
    ------
    WiringError
        If two signals have the same port name.
    """
    cls = type(component)
    signals: dict[str, SignalInstance] = {}
    slots: dict[str, Callable[..., Any]] = {}

    for attr in dir(cls):
        declared = getattr(cls, attr, None)
        if isinstance(declared, Signal) and not attr.startswith("_"):
            signals[attr] = getattr(component, attr)
        elif isinstance(getattr(declared, SLOT_ATTR, None), Slot):
            slots[port_name(getattr(component, attr))] = getattr(component, attr)

    for group_name, value in getattr(component, "__dict__", {}).items():
        if isinstance(value, SignalGroup):
            for member in value:
                if member in signals:
                    raise WiringError(
                        f"{cls.__name__} exposes two signals named {member!r}: "
                        f"the member of group {group_name!r} and an attribute of "
                        "the same name"
                    )
                signals[member] = value[member]

    return Ports(signals=signals, slots=slots)

slot

slot(fn: F) -> F
slot(
    *, name: str | None = ..., thread: SlotThread = ...
) -> Callable[[F], F]
slot(
    fn: F | None = None,
    /,
    *,
    name: str | None = None,
    thread: SlotThread = None,
) -> F | Callable[[F], F]

Mark a method as connectable to a signal.

A marked method's name and signature are public API, since other components connect to them; an unmarked method cannot be connected. async def methods can be marked too.

Parameters:

Name Type Description Default
name str | None

Port name in a configuration file. Defaults to the method name without leading underscores.

None
thread SlotThread

Thread the slot runs on, overriding the class's affinity.

None
Source code in src/redsun/virtual/_wiring.py
def slot(
    fn: F | None = None,
    /,
    *,
    name: str | None = None,
    thread: SlotThread = None,
) -> F | Callable[[F], F]:
    """Mark a method as connectable to a signal.

    A marked method's name and signature are public API, since other components
    connect to them; an unmarked method cannot be connected. `async def`
    methods can be marked too.

    Parameters
    ----------
    name : str | None
        Port name in a configuration file. Defaults to the method name without
        leading underscores.
    thread : SlotThread
        Thread the slot runs on, overriding the class's affinity.
    """

    def deco(target: F) -> F:
        setattr(target, SLOT_ATTR, Slot(name, thread))
        return target

    return deco if fn is None else deco(fn)