Skip to content

Virtual container

CallbackType module-attribute

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

Type alias for document callback functions.

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]

Cache type for storing signal instances registered from component classes.

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 for Redsun applications.

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. If not provided, default is "redsun".

metadata NotRequired[dict[str, Any]]

Additional session-specific metadata to include in the configuration.

Source code in src/redsun/virtual/_config.py
class RedSunConfig(TypedDict, total=False):
    """Base configuration schema for Redsun applications."""

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

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

    session: NotRequired[str]
    """Session display name. If not provided, default is `"redsun"`."""

    metadata: NotRequired[dict[str, Any]]
    """Additional session-specific metadata to include in the configuration."""

VirtualContainer

Bases: DynamicContainer, Loggable

Data exchange and dependency injection layer.

VirtualContainer is a DynamicContainer that also acts as a runtime signal bus and data sharing layer for an application.

Source code in src/redsun/virtual/_container.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
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
class VirtualContainer(dic.DynamicContainer, Loggable):
    """Data exchange and dependency injection layer.

    `VirtualContainer` is a [`DynamicContainer`][dependency_injector.containers.DynamicContainer]
    that also acts as a runtime signal bus and data sharing layer for an application.
    """

    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._names: dict[int, str] = {}
        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 plugin schema version specified in the configuration."""
        return self._config().schema_version

    @property
    def frontend(self) -> str:
        """The frontend toolkit identifier specified in the configuration."""
        return self._config().frontend

    @property
    def session(self) -> str:
        """The session display name specified in the configuration."""
        return self._config().session

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

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

        Private for use by the application layer 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*. Providers are bound during
            ``register_providers``, so a key read before that phase is unbound
            even when the owning component is present.
        """
        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`` for an optional collaborator that this
            application does not include.
        """
        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, or its signature is incompatible
            with ``(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 or its signature is incompatible
            with ``(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 currently registered document callbacks."""
        return self._callbacks()

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

    def _set_components(self, components: Mapping[str, object]) -> None:
        """Record the names built components are known by, for the wiring report."""
        self._components = dict(components)
        self._names = {id(component): name for name, component in components.items()}

    def _label(self, component: object | None) -> str:
        if component is None:
            return "<unknown>"
        return self._names.get(id(component), 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
            Delivery thread. Defaults to the affinity the slot declares, then
            to the one its class declares.

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

        Raises
        ------
        WiringError
            If *slot* is not marked as connectable, or if psygnal rejects the
            two signatures.
        """
        declaration = getattr(slot, SLOT_ATTR, None)
        if not isinstance(declaration, Slot):
            name = getattr(slot, "__qualname__", repr(slot))
            raise WiringError(
                f"{name} is not connectable; mark it with the 'slot' decorator"
            )

        consumer = getattr(slot, "__self__", None)
        if thread is None:
            thread = declaration.thread or cast(
                "SlotThread", getattr(type(consumer), SLOT_THREAD_ATTR, None)
            )

        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.

        Delivery is marshalled through a psygnal signal, so *thread* behaves as
        it does for `connect`. This is the only way a device signal can reach a
        slot with a thread affinity: ophyd-async calls its subscribers on
        whatever 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
            Delivery thread. Defaults to the affinity the slot declares, then
            to the one its class declares.

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

        Raises
        ------
        WiringError
            If *slot* is not marked as connectable.
        """
        declaration = getattr(slot, SLOT_ATTR, None)
        if not isinstance(declaration, Slot):
            name = getattr(slot, "__qualname__", repr(slot))
            raise WiringError(
                f"{name} is not connectable; mark it with the 'slot' decorator"
            )

        consumer = getattr(slot, "__self__", None)
        if thread is None:
            thread = declaration.thread or cast(
                "SlotThread", getattr(type(consumer), SLOT_THREAD_ATTR, None)
            )

        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 the ``wiring`` section of a
        configuration file. A signal port is the signal's attribute name, or the
        member name when it belongs to 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
            Delivery thread, overriding the slot and its class.

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

        Raises
        ------
        WiringError
            If either path is malformed, names a component that was not built,
            or names a port that component does not expose. The message lists
            what does exist.
        """
        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 WiringError(
                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.

        The complement of `connections` and `subscriptions`: what a component
        offers and nothing uses. A forgotten connection leaves no trace
        anywhere else, since a port that is never named 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()

schema_version property

schema_version: float

The plugin schema version specified in the configuration.

frontend property

frontend: str

The frontend toolkit identifier specified in the configuration.

session property

session: str

The session display name specified in the configuration.

metadata property

metadata: dict[str, object]

The session metadata specified in the configuration.

callbacks property

callbacks: dict[str, CallbackType]

The currently registered document callbacks.

signals property

signals: dict[str, SignalCache]

The currently 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.

The complement of connections and subscriptions: what a component offers and nothing uses. A forgotten connection leaves no trace anywhere else, since a port that is never named 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. Providers are bound during register_providers, so a key read before that phase is unbound even when the owning component is present.

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*. Providers are bound during
        ``register_providers``, so a key read before that phase is unbound
        even when the owning component is present.
    """
    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 for an optional collaborator that this application does not include.

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`` for an optional collaborator that this
        application does not include.
    """
    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 or its signature is incompatible with (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 or its signature is incompatible
        with ``(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

Delivery thread. Defaults to the affinity the slot declares, then to the one its class declares.

None

Returns:

Type Description
Connection

The recorded link.

Raises:

Type Description
WiringError

If slot is not marked as connectable, or if psygnal rejects the two 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
        Delivery thread. Defaults to the affinity the slot declares, then
        to the one its class declares.

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

    Raises
    ------
    WiringError
        If *slot* is not marked as connectable, or if psygnal rejects the
        two signatures.
    """
    declaration = getattr(slot, SLOT_ATTR, None)
    if not isinstance(declaration, Slot):
        name = getattr(slot, "__qualname__", repr(slot))
        raise WiringError(
            f"{name} is not connectable; mark it with the 'slot' decorator"
        )

    consumer = getattr(slot, "__self__", None)
    if thread is None:
        thread = declaration.thread or cast(
            "SlotThread", getattr(type(consumer), SLOT_THREAD_ATTR, None)
        )

    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.

Delivery is marshalled through a psygnal signal, so thread behaves as it does for connect. This is the only way a device signal can reach a slot with a thread affinity: ophyd-async calls its subscribers on whatever 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

Delivery thread. Defaults to the affinity the slot declares, then to the one its class declares.

None

Returns:

Type Description
Subscription

The recorded subscription.

Raises:

Type Description
WiringError

If slot is not marked as connectable.

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.

    Delivery is marshalled through a psygnal signal, so *thread* behaves as
    it does for `connect`. This is the only way a device signal can reach a
    slot with a thread affinity: ophyd-async calls its subscribers on
    whatever 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
        Delivery thread. Defaults to the affinity the slot declares, then
        to the one its class declares.

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

    Raises
    ------
    WiringError
        If *slot* is not marked as connectable.
    """
    declaration = getattr(slot, SLOT_ATTR, None)
    if not isinstance(declaration, Slot):
        name = getattr(slot, "__qualname__", repr(slot))
        raise WiringError(
            f"{name} is not connectable; mark it with the 'slot' decorator"
        )

    consumer = getattr(slot, "__self__", None)
    if thread is None:
        thread = declaration.thread or cast(
            "SlotThread", getattr(type(consumer), SLOT_THREAD_ATTR, None)
        )

    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 the wiring section of a configuration file. A signal port is the signal's attribute name, or the member name when it belongs to 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

Delivery thread, overriding the slot and its class.

None

Returns:

Type Description
Connection

The recorded link.

Raises:

Type Description
WiringError

If either path is malformed, names a component that was not built, or names a port that component does not expose. The message lists what does exist.

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 the ``wiring`` section of a
    configuration file. A signal port is the signal's attribute name, or the
    member name when it belongs to 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
        Delivery thread, overriding the slot and its class.

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

    Raises
    ------
    WiringError
        If either path is malformed, names a component that was not built,
        or names a port that component does not expose. The message lists
        what does exist.
    """
    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

Protocol marking your class as capable of shutting down synchronously.

Source code in src/redsun/virtual/_protocols.py
@runtime_checkable
class HasShutdown(Protocol):  # pragma: no cover
    """Protocol marking your class as capable of shutting down synchronously."""

    @abstractmethod
    def shutdown(self) -> None:
        """Shutdown an object. Perform cleanup operations.

        For use in presenters.
        """
        ...

shutdown abstractmethod

shutdown() -> None

Shutdown an object. Perform cleanup operations.

For use in presenters.

Source code in src/redsun/virtual/_protocols.py
@abstractmethod
def shutdown(self) -> None:
    """Shutdown an object. Perform cleanup operations.

    For use in presenters.
    """
    ...

IsInjectable

Bases: Protocol

Protocol marking a class as injectable with dependencies from the container.

Source code in src/redsun/virtual/_protocols.py
@runtime_checkable
class IsInjectable(Protocol):  # pragma: no cover
    """Protocol marking a class as injectable with dependencies from the 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

Protocol marking a class as a provider of dependencies.

Source code in src/redsun/virtual/_protocols.py
@runtime_checkable
class IsProvider(Protocol):  # pragma: no cover
    """Protocol marking a class as a provider of dependencies."""

    @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."""
    ...

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 signal listed here emits into nothing; a slot listed here 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 signal listed here emits into
    nothing; a slot listed here 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, in which case the member name is the port 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 claim the same port name, which would leave the component unaddressable.

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, in which case the
    member name is the port 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 claim the same port name, which would leave the
        component unaddressable.
    """
    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 is public API: its name and signature are what other components are connected against, and an unmarked method cannot be connected at all. async def methods may be marked too.

Parameters:

Name Type Description Default
name str | None

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

None
thread SlotThread

Delivery thread, overriding the affinity the class declares.

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 is public API: its name and signature are what other
    components are connected against, and an unmarked method cannot be
    connected at all. `async def` methods may be marked too.

    Parameters
    ----------
    name : str | None
        Port name a configuration file addresses the method by. Defaults to
        the method name without leading underscores.
    thread : SlotThread
        Delivery thread, overriding the affinity the class declares.
    """

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

    return deco if fn is None else deco(fn)