Skip to content

Container

The declare_* functions and the component wrappers they create.

declare_device

declare_device(
    cls: type[T],
    /,
    alias: str | None = None,
    from_config: str | None = None,
    **kwargs: Any,
) -> T

Declare a device on a container.

class MyApp(AppContainer):
    motor = declare_device(MyMotor, axis=["X"])

The attribute's type is cls, so on a built container it is a MyMotor.

Parameters:

Name Type Description Default
cls type[T]

The component class to instantiate.

required
alias str | None

Component name, overriding the attribute name.

None
from_config str | None

Key in the configuration file's devices section holding more keyword arguments. The file is the one declared by the container class using the field, so a subclass with its own config reads its own.

None
**kwargs Any

Keyword arguments passed to the constructor.

{}
Source code in src/redsun/containers/components.py
def declare_device(
    cls: type[T],
    /,
    alias: str | None = None,
    from_config: str | None = None,
    **kwargs: Any,
) -> T:
    """Declare a device on a container.

    ```python
    class MyApp(AppContainer):
        motor = declare_device(MyMotor, axis=["X"])
    ```

    The attribute's type is *cls*, so on a built container it is a `MyMotor`.

    Parameters
    ----------
    cls : type[T]
        The component class to instantiate.
    alias : str | None
        Component name, overriding the attribute name.
    from_config : str | None
        Key in the configuration file's ``devices`` section holding more
        keyword arguments. The file is the one declared by the container class
        using the field, so a subclass with its own ``config`` reads its own.
    **kwargs : Any
        Keyword arguments passed to the constructor.
    """
    return cast(
        "T", _DeviceField(cls=cls, alias=alias, from_config=from_config, kwargs=kwargs)
    )

declare_hook

declare_hook(provider: type[T], /, **kwargs: Any) -> T
declare_hook(provider: T) -> T
declare_hook(provider: Any, /, **kwargs: Any) -> Any

Declare a hook provider for the hook point the attribute names.

The attribute name is the method the hook point calls, so a container has at most one provider per point:

class MyApp(QtAppContainer):
    configure_application = declare_hook(DarkTheme, theme="nord")

A class is constructed with kwargs when the container class is created; an instance is used as is, and one instance declared at two points serves both.

Parameters:

Name Type Description Default
provider type[T] | T

The provider class to instantiate, or a provider already built.

required
**kwargs Any

Keyword arguments passed to the provider's constructor.

{}

Raises:

Type Description
TypeError

If keyword arguments are given with an instance.

Source code in src/redsun/containers/components.py
def declare_hook(provider: Any, /, **kwargs: Any) -> Any:
    """Declare a hook provider for the hook point the attribute names.

    The attribute name is the method the hook point calls, so a container has
    at most one provider per point:

    ```python
    class MyApp(QtAppContainer):
        configure_application = declare_hook(DarkTheme, theme="nord")
    ```

    A class is constructed with *kwargs* when the container class is created;
    an instance is used as is, and one instance declared at two points serves
    both.

    Parameters
    ----------
    provider : type[T] | T
        The provider class to instantiate, or a provider already built.
    **kwargs : Any
        Keyword arguments passed to the provider's constructor.

    Raises
    ------
    TypeError
        If keyword arguments are given with an instance.
    """
    if not isinstance(provider, type) and kwargs:
        raise TypeError(
            f"declare_hook takes keyword arguments only with a class; "
            f"{type(provider).__name__} is already constructed"
        )
    return _HookField(provider=provider, kwargs=kwargs)

declare_presenter

declare_presenter(
    cls: type[T],
    /,
    alias: str | None = None,
    from_config: str | None = None,
    **kwargs: Any,
) -> T

Declare a presenter on a container.

class MyApp(AppContainer):
    ctrl = declare_presenter(MyCtrl, gain=1.0)

The attribute's type is cls, so connections in wire are type-checked: naming a signal or slot the class lacks is an error before the build.

Parameters:

Name Type Description Default
cls type[T]

The component class to instantiate.

required
alias str | None

Component name, overriding the attribute name.

None
from_config str | None

Key in the configuration file's presenters section holding more keyword arguments. The file is the one declared by the container class using the field, so a subclass with its own config reads its own.

None
**kwargs Any

Keyword arguments passed to the constructor.

{}
Source code in src/redsun/containers/components.py
def declare_presenter(
    cls: type[T],
    /,
    alias: str | None = None,
    from_config: str | None = None,
    **kwargs: Any,
) -> T:
    """Declare a presenter on a container.

    ```python
    class MyApp(AppContainer):
        ctrl = declare_presenter(MyCtrl, gain=1.0)
    ```

    The attribute's type is *cls*, so connections in
    [`wire`][redsun.containers.container.AppContainer.wire] are type-checked:
    naming a signal or slot the class lacks is an error before the build.

    Parameters
    ----------
    cls : type[T]
        The component class to instantiate.
    alias : str | None
        Component name, overriding the attribute name.
    from_config : str | None
        Key in the configuration file's ``presenters`` section holding more
        keyword arguments. The file is the one declared by the container class
        using the field, so a subclass with its own ``config`` reads its own.
    **kwargs : Any
        Keyword arguments passed to the constructor.
    """
    return cast(
        "T",
        _PresenterField(cls=cls, alias=alias, from_config=from_config, kwargs=kwargs),
    )

declare_service

declare_service(
    *,
    module: str | None = None,
    ready: str | None = None,
    prefix: str = "",
    args: Sequence[str] = (),
    stop_timeout: float = STOP_TIMEOUT,
    alias: str | None = None,
) -> Service

Declare a service the container's devices talk to.

A service with a module is launched as python -m <module> <args> when the container is built; one without is attached to, already running elsewhere:

class MyApp(AppContainer):
    camera_ioc = declare_service(
        module="mylab.iocs.camera", ready="Server startup complete.", prefix="CAM:"
    )
    camera = declare_device(MyCamera, service="camera_ioc")

A device naming the service receives its prefix as prefix. The attribute's type is redsun.services.Service, so wire can connect its sig_exited.

Parameters:

Name Type Description Default
module str | None

Module to run. None attaches to a service that is already running.

None
ready str | None

Text of the output line marking a launched service ready. None counts it ready once its process starts.

None
prefix str

Prefix given to each device naming the service.

''
args Sequence[str]

Command-line arguments following the module.

()
stop_timeout float

Seconds each stopping step waits for the service to exit.

STOP_TIMEOUT
alias str | None

Service name, overriding the attribute name.

None
Source code in src/redsun/containers/components.py
def declare_service(
    *,
    module: str | None = None,
    ready: str | None = None,
    prefix: str = "",
    args: Sequence[str] = (),
    stop_timeout: float = STOP_TIMEOUT,
    alias: str | None = None,
) -> Service:
    """Declare a service the container's devices talk to.

    A service with a *module* is launched as ``python -m <module> <args>`` when
    the container is built; one without is attached to, already running
    elsewhere:

    ```python
    class MyApp(AppContainer):
        camera_ioc = declare_service(
            module="mylab.iocs.camera", ready="Server startup complete.", prefix="CAM:"
        )
        camera = declare_device(MyCamera, service="camera_ioc")
    ```

    A device naming the service receives its prefix as ``prefix``. The
    attribute's type is `redsun.services.Service`, so ``wire`` can connect its
    ``sig_exited``.

    Parameters
    ----------
    module : str | None
        Module to run. ``None`` attaches to a service that is already running.
    ready : str | None
        Text of the output line marking a launched service ready. ``None``
        counts it ready once its process starts.
    prefix : str
        Prefix given to each device naming the service.
    args : Sequence[str]
        Command-line arguments following the module.
    stop_timeout : float
        Seconds each stopping step waits for the service to exit.
    alias : str | None
        Service name, overriding the attribute name.
    """
    kwargs: dict[str, Any] = {
        "module": module,
        "ready": ready,
        "prefix": prefix,
        "args": args,
        "stop_timeout": stop_timeout,
    }
    return cast("Service", _ServiceComponent(alias or "", **kwargs))

declare_view

declare_view(
    cls: type[T],
    /,
    alias: str | None = None,
    from_config: str | None = None,
    **kwargs: Any,
) -> T

Declare a view on a container.

class MyApp(AppContainer):
    ui = declare_view(MyView)

The attribute's type is cls, so on a built container it is a MyView.

Parameters:

Name Type Description Default
cls type[T]

The component class to instantiate.

required
alias str | None

Component name, overriding the attribute name.

None
from_config str | None

Key in the configuration file's views section holding more keyword arguments. The file is the one declared by the container class using the field, so a subclass with its own config reads its own.

None
**kwargs Any

Keyword arguments passed to the constructor.

{}
Source code in src/redsun/containers/components.py
def declare_view(
    cls: type[T],
    /,
    alias: str | None = None,
    from_config: str | None = None,
    **kwargs: Any,
) -> T:
    """Declare a view on a container.

    ```python
    class MyApp(AppContainer):
        ui = declare_view(MyView)
    ```

    The attribute's type is *cls*, so on a built container it is a `MyView`.

    Parameters
    ----------
    cls : type[T]
        The component class to instantiate.
    alias : str | None
        Component name, overriding the attribute name.
    from_config : str | None
        Key in the configuration file's ``views`` section holding more keyword
        arguments. The file is the one declared by the container class using
        the field, so a subclass with its own ``config`` reads its own.
    **kwargs : Any
        Keyword arguments passed to the constructor.
    """
    return cast(
        "T", _ViewField(cls=cls, alias=alias, from_config=from_config, kwargs=kwargs)
    )

AppContainer

Application container of a DVP session.

Parameters:

Name Type Description Default
session str

Session display name.

'Redsun'
frontend str

Frontend toolkit identifier.

'pyqt'
log_level int or str

Level of the redsun logger, as a logging constant or a level name. Unchanged when not given.

None
Source code in src/redsun/containers/container.py
 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
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
class AppContainer:
    """Application container of a DVP session.

    Parameters
    ----------
    session : str
        Session display name.
    frontend : str
        Frontend toolkit identifier.
    log_level : int or str, optional
        Level of the ``redsun`` logger, as a `logging` constant or a level
        name. Unchanged when not given.
    """

    __slots__ = (
        "_built",
        "_built_devices",
        "_catalog",
        "_components",
        "_config",
        "_failed",
        "_failed_services",
        "_hook_by_moment",
        "_hooks",
        "_is_built",
        "_path_provider",
        "_report",
        "_service_logs",
        "_services",
        "_services_started",
        "_session_log",
        "_storage",
        "_virtual_container",
    )

    _service_components: ClassVar[dict[str, _ServiceComponent]] = {}
    _device_components: ClassVar[dict[str, _DeviceComponent]] = {}
    _presenter_components: ClassVar[dict[str, _PresenterComponent]] = {}
    _view_components: ClassVar[dict[str, _ViewComponent]] = {}
    _component_fields: ClassVar[dict[str, _ComponentField]] = {}
    """Every ``declare_*`` field of this container and its bases.

    Kept after class creation, so a subclass with its own ``config`` resolves
    inherited fields against its own file.
    """

    _config_paths: ClassVar[tuple[Path, ...]] = ()
    """The configuration files this container reads, in layering order.

    A subclass's ``config`` is appended to its bases' files, so a file shared
    by several sessions sits under each session's own.
    """

    BUILD_STEPS: ClassVar[tuple[str, ...]] = (
        "services",
        "virtual container",
        "devices",
        "connect",
        "presenters",
        "views",
        "providers",
        "wiring",
        "injection",
    )
    """The steps `build` announces, in order.

    Each is reported when it starts, so a progress display can size itself from
    this tuple.
    """

    _hook_keys: ClassVar[Mapping[str, type]] = {}
    """The hook points this container calls, in order.

    A key is the method the point calls, which names the point in a container
    class body and in the ``hooks`` section. Empty here: every hook point
    belongs to a toolkit, whose container declares it.
    """

    _hook_providers: ClassVar[dict[str, object]] = {}
    """The providers declared on this container class, by hook point.

    Built when the class is created, so an instance declared at two points
    serves both. A subclass inherits its bases' providers.
    """

    def __init_subclass__(
        cls,
        config: str | Path | Sequence[str | Path] | None = None,
        **kwargs: Any,
    ) -> None:
        """Collect the component declarations of the class body.

        Parameters
        ----------
        config : str | Path | Sequence[str | Path] | None
            YAML file of component keyword arguments, or several layered in
            order. They are read after the bases' files, and a later file wins
            a shared key.
        """
        super().__init_subclass__(**kwargs)

        declared = (
            []
            if config is None
            else [config]
            if isinstance(config, (str, Path))
            else list(config)
        )
        inherited: list[Path] = []
        for base in cls.__bases__:
            if issubclass(base, AppContainer):
                inherited.extend(base._config_paths)
        # a base named twice through two paths of the hierarchy contributes its
        # files once, in the order the first path reached them
        seen: dict[Path, None] = {}
        for path in (*inherited, *(Path(entry) for entry in declared)):
            seen.setdefault(path, None)
        cls._config_paths = tuple(seen)

        services: dict[str, _ServiceComponent] = {}
        devices: dict[str, _DeviceComponent] = {}
        presenters: dict[str, _PresenterComponent] = {}
        views: dict[str, _ViewComponent] = {}

        for base in cls.__bases__:
            if issubclass(base, AppContainer):
                services.update(base._service_components)
                devices.update(base._device_components)
                presenters.update(base._presenter_components)
                views.update(base._view_components)

        namespace = vars(cls)

        for attr_name, attr_value in namespace.items():
            if attr_name.startswith("_"):
                continue

            if isinstance(attr_value, _ServiceComponent):
                # made once here so that keywords a Service refuses are refused
                # as the class is created; raised from __set_name__, Python 3.11
                # would wrap the error in a RuntimeError
                attr_value.create()
                services[attr_value.name] = attr_value
            elif isinstance(attr_value, _DeviceComponent):
                devices[attr_value.name] = attr_value
            elif isinstance(attr_value, _PresenterComponent):
                presenters[attr_value.name] = attr_value
            elif isinstance(attr_value, _ViewComponent):
                views[attr_value.name] = attr_value

        component_fields: dict[str, _ComponentField] = {}
        for base in cls.__bases__:
            if issubclass(base, AppContainer):
                component_fields.update(base._component_fields)
        component_fields.update(
            {
                attr_name: value
                for attr_name, value in namespace.items()
                if not attr_name.startswith("_") and isinstance(value, _ComponentField)
            }
        )
        cls._component_fields = component_fields

        if component_fields:
            config_data: dict[str, Any] = {}
            if cls._config_paths:
                config_data = _load_yaml(cls._config_paths)

            _section_key: dict[type, str] = {
                _DeviceField: "devices",
                _PresenterField: "presenters",
                _ViewField: "views",
            }

            for attr_name, field in component_fields.items():
                kw = field.kwargs
                if field.from_config is not None and config_data:
                    section_key = _section_key[type(field)]
                    # a section written with nothing under it parses as None
                    section_data: dict[str, Any] = config_data.get(section_key) or {}
                    _sentinel = object()
                    cfg_section = section_data.get(field.from_config, _sentinel)

                    if cfg_section is _sentinel:
                        logger.warning(
                            f"No config section '{field.from_config}' found in "
                            f"'{section_key}' for component field '{attr_name}' in {cls.__name__}, "
                            f"using inline kwargs only"
                        )
                        kw = field.kwargs
                    else:
                        kw = {**(cfg_section or {}), **field.kwargs}

                comp_name = field.alias if field.alias is not None else attr_name

                wrapper: _DeviceComponent | _PresenterComponent | _ViewComponent
                if isinstance(field, _DeviceField):
                    wrapper = _DeviceComponent(field.cls, comp_name, **kw)
                    devices[comp_name] = wrapper
                elif isinstance(field, _PresenterField):
                    wrapper = _PresenterComponent(field.cls, comp_name, **kw)
                    presenters[comp_name] = wrapper
                else:
                    wrapper = _ViewComponent(field.cls, comp_name, **kw)
                    views[comp_name] = wrapper
                setattr(cls, attr_name, wrapper)

        cls._service_components = services
        cls._device_components = devices
        cls._presenter_components = presenters
        cls._view_components = views

        hook_providers: dict[str, object] = {}
        for base in cls.__bases__:
            if issubclass(base, AppContainer):
                hook_providers.update(base._hook_providers)

        hook_fields = {
            attr_name: value
            for attr_name, value in namespace.items()
            if isinstance(value, _HookField)
        }
        for attr_name, hook_field in hook_fields.items():
            provider = cls._build_hook_provider(attr_name, hook_field)
            hook_providers[attr_name] = provider
            setattr(cls, attr_name, provider)
        cls._hook_providers = hook_providers

        if devices or presenters or views:
            logger.debug(
                f"Collected from {cls.__name__}: "
                f"{len(devices)} devices, "
                f"{len(presenters)} presenters, "
                f"{len(views)} views"
            )

    def __init__(
        self,
        *,
        session: str = "Redsun",
        frontend: str = "pyqt",
        log_level: int | str | None = None,
    ) -> None:
        self._refuse_unresolved_fields()
        if log_level is not None:
            set_level(log_level)
        self._config: AppConfig = {
            "schema_version": 1.0,
            "session": session,
            "frontend": frontend,
        }
        self._virtual_container: VirtualContainer | None = None
        self._path_provider: SessionPathProvider | None = None
        self._storage: StorageConfig | None = None
        self._catalog: SimpleTiledServer | None = None
        self._hooks: tuple[object, ...] | None = None
        self._hook_by_moment: dict[str, object] = {}
        self._is_built: bool = False
        # what this container built, keyed by the declaration it was built
        # from. The declaration registries are class attributes shared by every
        # container of the class; this is per container, so the objects go when
        # it does and the next container starts from nothing.
        self._built: dict[_ComponentBase[Any], Any] = {}
        # what the build could not make, by component name, so that a phase
        # after the one that failed can tell a component that is not there
        # from a name that was never declared
        self._failed: dict[str, BaseException] = {}
        self._built_devices: dict[str, Device] = {}
        self._components: dict[str, _ComponentBase[Any]] = {
            **self._presenter_components,
            **self._view_components,
        }
        self._report: Callable[[str], None] = _silent
        # a container launches and stops processes of its own, so each one
        # makes its services from the declarations the class shares
        self._services: dict[str, Service] = {
            name: declaration.create()
            for name, declaration in self._service_components.items()
        }
        self._failed_services: dict[str, BaseException] = {}
        self._services_started: bool = False

        # In the declarative subclass path (class MyApp(QtAppContainer, config=...))
        # the metaclass loads the YAML only to resolve component kwargs and never
        # populates _config with top-level sections such as 'storage', 'session',
        # or 'schema_version'.  We read those here so that build() sees the same
        # state as the from_config() path, which sets them explicitly.
        config_paths: tuple[Path, ...] = getattr(type(self), "_config_paths", ())
        if config_paths:
            try:
                yaml_data = _load_yaml(config_paths)
            except Exception as e:  # noqa: BLE001 - unreadable config falls back to defaults
                named = ", ".join(str(path) for path in config_paths)
                logger.warning(f"Could not read config file(s) {named}: {e}")
                yaml_data = {}
            for key, value in yaml_data.items():
                if key not in _COMPONENT_SECTIONS:
                    self._config[key] = value  # type: ignore[literal-required]

        self._session_log: SessionFileHandler | None = None
        self._service_logs: dict[str, SessionFileHandler] = {}
        self._open_session_log()

    @classmethod
    def _refuse_unresolved_fields(cls) -> None:
        """Refuse a container whose ``from_config`` fields have no file.

        Checked at construction, not class creation, since a base class leaves
        ``config`` to its subclasses.

        Raises
        ------
        TypeError
            Naming every field wanting a configuration section.
        """
        if cls._config_paths:
            return
        unresolved = sorted(
            attr_name
            for attr_name, field in cls._component_fields.items()
            if field.from_config is not None
        )
        if unresolved:
            raise TypeError(
                f"Component field(s) {', '.join(unresolved)} in {cls.__name__} have "
                f"from_config set but no config path was provided to the container "
                f"class"
            )

    @property
    def config(self) -> AppConfig:
        """Return the application configuration."""
        return self._config

    def _built_of(self, declared: Mapping[str, _ComponentBase[T]]) -> dict[str, T]:
        """Return what this container built from *declared*, by name.

        A declaration not reached or failed is absent, so the mapping can be
        shorter than *declared*.
        """
        return {
            name: cast("T", self._built[comp])
            for name, comp in declared.items()
            if comp in self._built
        }

    @property
    def devices(self) -> dict[str, Device]:
        """Return the built devices."""
        if not self._is_built:
            raise RuntimeError("Container not built. Call build() first.")
        return self._built_of(self._device_components)

    @property
    def presenters(self) -> dict[str, PPresenter]:
        """Return the built presenters."""
        if not self._is_built:
            raise RuntimeError("Container not built. Call build() first.")
        return self._built_of(self._presenter_components)

    @property
    def views(self) -> dict[str, PView]:
        """Return the built views."""
        if not self._is_built:
            raise RuntimeError("Container not built. Call build() first.")
        return self._built_of(self._view_components)

    @property
    def services(self) -> dict[str, Service]:
        """Return the container's services, started or not."""
        return dict(self._services)

    @property
    def storage(self) -> StorageConfig:
        """Return the session's storage configuration."""
        if self._storage is None:
            raise RuntimeError("Container not built. Call build() first.")
        return self._storage

    @property
    def path_provider(self) -> SessionPathProvider:
        """Return the session's path provider, shared by every device taking one."""
        if self._path_provider is None:
            raise RuntimeError("Container not built. Call build() first.")
        return self._path_provider

    @property
    def virtual_container(self) -> VirtualContainer:
        """Return the virtual container."""
        if self._virtual_container is None:
            raise RuntimeError("Container not built. Call build() first.")
        return self._virtual_container

    @property
    def is_built(self) -> bool:
        """Return whether the container has been built."""
        return self._is_built

    def wire(self) -> None:
        """Connect the signals and slots of built components.

        Override it to declare an application's connections. Every component is
        built when this runs, available as the attribute it was declared under:

        ```python
        class MyApp(AppContainer):
            det_ctrl = declare_presenter(DetectorPresenter)
            img_widget = declare_view(ImageView)

            def wire(self) -> None:
                self.connect(self.det_ctrl.sig_new_data, self.img_widget.update_layers)
        ```

        Connects nothing by default.
        """

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

        Returns ``None`` without connecting when an end belongs to a component
        that failed to build: the link is logged at ``WARNING`` and `wire`
        continues. Any other wrong port raises.

        See [`VirtualContainer.connect`][redsun.virtual.VirtualContainer.connect].
        """
        ends: tuple[object, object] = (signal, slot)
        absent = {end.component for end in ends if isinstance(end, _NotBuilt)}
        if absent:
            named = ", ".join(repr(name) for name in sorted(absent))
            logger.warning(
                f"Not connecting {self._end_path(signal)} -> "
                f"{self._end_path(slot)}: {named} not built"
            )
            return None
        return self.virtual_container.connect(signal, slot, thread=thread)

    def _end_path(self, end: object) -> str:
        """Return one end of a connection as ``component.port``."""
        if isinstance(end, _NotBuilt):
            return str(end)
        owner = getattr(end, "__self__", None) or getattr(end, "instance", None)
        port = getattr(end, "name", None) or getattr(end, "__name__", "<anonymous>")
        return f"{self.virtual_container._label(owner)}.{port}"

    def _apply_wiring_config(self) -> None:
        """Connect the port pairs listed in the ``wiring`` configuration section.

        A rule naming a component that failed to build is logged and skipped.
        Any other wrong rule raises, including an undeclared name.
        """
        for index, rule in enumerate(self._config.get("wiring", [])):
            if not isinstance(rule, dict) or rule.keys() != {"from", "to"}:
                raise WiringError(
                    f"wiring entry {index} must be a mapping with exactly the "
                    f"keys 'from' and 'to', got {rule!r}"
                )
            try:
                self.virtual_container.connect_paths(rule["from"], rule["to"])
            except ComponentNotBuilt as e:
                if e.component not in self._failed:
                    raise
                logger.warning(
                    f"Not connecting {rule['from']} -> {rule['to']}: "
                    f"component {e.component!r} was not built"
                )

    def build(self) -> Self:
        """Build every component in dependency order.

        The order is fixed, and each step is announced as it starts:

        1. Services, through `start_services`
        2. VirtualContainer
        3. Devices
        4. Connect, every device declared with ``autoconnect`` true
        5. Presenters
        6. Views
        7. Providers, registered into the VirtualContainer
        8. Wiring, connecting the signals and slots of built components
        9. Remaining dependency injection

        A build that raises stops the services first, so no process it launched
        outlives it.
        """
        if self._is_built:
            logger.warning("Container already built, skipping rebuild")
            return self

        get_shared_loop()
        self._open_session_log()

        logger.info("Building application container...")

        # resolved even by a container that calls no hook point of its own, so
        # that a malformed hooks section is refused wherever it is built
        self._ensure_hooks()

        try:
            self._report("services")
            self.start_services()
            self._report("virtual container")
            self._create_virtual_container()
            self._report("devices")
            self._build_devices()
            self._report("connect")
            self._connect_devices()
            self._report("presenters")
            self._build_presenters()
            self._report("views")
            self._build_views()
            self._report("providers")
            self._register_providers()
            self._report("wiring")
            self._apply_wiring()
            self._report("injection")
            self._inject_dependencies()
        except BaseException:
            self._close_catalog()
            self._stop_services()
            raise

        self._is_built = True
        summary = self._summarise_build()
        if self._failed:
            logger.warning(summary)
        else:
            logger.info(summary)

        return self

    def _summarise_build(self) -> str:
        """Return what the build made, counted against what was declared.

        One line if nothing was missed; otherwise further lines name what was
        not made.
        """
        declared: tuple[tuple[str, Mapping[str, _ComponentBase[Any]]], ...] = (
            ("device", self._device_components),
            ("presenter", self._presenter_components),
            ("view", self._view_components),
        )
        counts = ", ".join(
            f"{len(self._built_of(components))}/{len(components)} {kind}s"
            for kind, components in declared
        )
        summary = f"Container built: {counts}"
        if self._failed:
            kind_of = {
                name: kind for kind, components in declared for name in components
            }
            missing = ", ".join(
                f"{name} ({kind_of.get(name, 'component')}"
                f"{', not connected' if isinstance(error, ConnectionError) else ''})"
                for name, error in self._failed.items()
            )
            summary = f"{summary}\nNot built: {missing}"
        # a service no device names is not unused: nothing was meant to use it
        named = {c.service for c in self._device_components.values()}
        used = {c.service for c in self._built if isinstance(c, _DeviceComponent)}
        unused = [
            f"{name} (no device built)"
            for name in self._services
            if name in named - used and name not in self._failed_services
        ]
        if unused:
            summary = f"{summary}\nUnused: {', '.join(unused)}"
        return summary

    def start_services(self) -> None:
        """Start every service the container launches, and attach to the rest.

        `build` calls this too. Only the first call until `shutdown` does
        anything. A service that fails to start is logged, and the build skips
        every device naming it; the rest of the session runs.
        """
        if self._services_started:
            return
        self._services_started = True
        if not self._services:
            return
        for name, service in self._services.items():
            try:
                service.start()
            except Exception as e:  # noqa: BLE001 - a missing service must not abort the app
                self._failed_services[name] = e
                logger.error(f"Failed to start service '{name}': {e}")
        summary = (
            f"Services started: {len(self._services) - len(self._failed_services)}"
            f"/{len(self._services)}"
        )
        if not self._failed_services:
            logger.info(summary)
            return
        failed = ", ".join(
            f"{name} ({reason})" for name, reason in self._failed_services.items()
        )
        logger.warning(f"{summary}\nNot started: {failed}")

    @classmethod
    def _build_hook_provider(cls, moment: str, field: _HookField) -> object:
        """Construct the provider this container class declares at *moment*.

        Raises
        ------
        HookError
            If *moment* is not a hook point this container calls, the provider
            rejects the keys given, or it does not implement the point's
            protocol.
        """
        if moment not in cls._hook_keys:
            raise HookError(
                f"{cls.__name__} declares a hook at {moment!r}, which is not a "
                f"hook point it calls; {known_points(cls._hook_keys)}"
            )
        declared = field.provider
        if isinstance(declared, type):
            try:
                provider: object = declared(**field.kwargs)
            except TypeError as e:
                raise HookError(
                    f"cannot construct hook provider {declared.__name__!r} "
                    f"declared at {moment!r} with {sorted(field.kwargs)}: {e}"
                ) from e
        else:
            provider = declared
        protocol = cls._hook_keys[moment]
        if not isinstance(provider, protocol):
            raise HookError(
                f"hook provider {type(provider).__name__!r} declared at "
                f"{moment!r} does not implement {protocol.__name__}"
            )
        return provider

    def _ensure_hooks(self) -> dict[str, object]:
        """Return the hook providers by hook point, resolved once per build.

        A subclass calling its own hook points uses this, so every hook point
        of a build uses one set of providers.
        """
        if self._hooks is None:
            self._hook_by_moment = self._resolve_hook_providers()
            self._hooks = distinct(
                self._hook_by_moment[moment]
                for moment in self._hook_keys
                if moment in self._hook_by_moment
            )
        return self._hook_by_moment

    def _resolve_hook_providers(self) -> dict[str, object]:
        """Merge the providers declared on the class with the configured ones.

        Raises
        ------
        HookError
            If an entry does not resolve, a hook point is named on both the
            class and the configuration, or a configured provider does not
            implement its point's protocol.
        """
        declared = dict(type(self)._hook_providers)
        configured = resolve_hooks(
            parse_hook_specs(
                self._config.get("hooks", {}), self._hook_keys, type(self).__name__
            )
        )
        both = sorted(declared.keys() & configured.keys())
        if both:
            named = ", ".join(repr(moment) for moment in both)
            raise HookError(
                f"hook point(s) {named} are named both on {type(self).__name__} "
                "and in the configuration; a hook point takes one provider, so "
                "drop one of the two"
            )
        for moment, hook in configured.items():
            protocol = self._hook_keys[moment]
            if not isinstance(hook, protocol):
                raise HookError(
                    f"hook provider {type(hook).__name__!r} configured at "
                    f"{moment!r} does not implement {protocol.__name__}"
                )
        return {**declared, **configured}

    def _shutdown_hooks(self) -> None:
        """Undo what the hook providers did, in reverse installation order."""
        for hook in reversed(self._hooks or ()):
            if isinstance(hook, HasShutdown):
                try:
                    hook.shutdown()
                except Exception as e:  # noqa: BLE001 - one failure must not block the rest
                    logger.error(
                        f"Error shutting down hook '{type(hook).__name__}': {e}"
                    )
        self._hooks = None
        self._hook_by_moment = {}

    def _create_virtual_container(self) -> None:
        """Create the VirtualContainer with the session configuration."""
        self._virtual_container = VirtualContainer()

        base_cfg: RedSunConfig = {
            "schema_version": self._config.get("schema_version", 1.0),
            "session": self._config.get("session", "Redsun"),
            "frontend": self._config.get("frontend", "pyqt"),
        }
        self._virtual_container._set_configuration(base_cfg)

        # parsed before the extra is checked, so a malformed section is refused
        # whether or not it is installed
        self._storage = StorageConfig.from_mapping(self._config.get("storage"))
        self._path_provider = SessionPathProvider(
            base_dir=self._storage.base_dir,
            session=base_cfg["session"],
            max_digits=self._storage.max_digits,
        )
        if self._storage.catalog is not None:
            _require_tiled()
            self._catalog = self._start_catalog(self._storage.catalog)
        logger.debug("VirtualContainer created")

    def _start_catalog(self, config: CatalogConfig) -> SimpleTiledServer | None:
        """Start the session's catalog, or log why it could not.

        It reads from the session's directory and every one *config* adds,
        serves OME-Zarr images with their axis names, and has a ``TiledWriter``
        in this process store them as their store holds them.
        """
        # imported here: the tiled extra is optional, and _require_tiled has
        # already refused a session asking for a catalog without it
        from ome_tiled import OME_ZARR_MIMETYPE, OmeZarrAdapter
        from ome_tiled.bluesky import register_consolidator
        from tiled.server.simple import SimpleTiledServer

        session_dir = self.path_provider.session_dir
        server: SimpleTiledServer | None = None
        try:
            server = SimpleTiledServer(
                directory=session_dir / "catalog",
                readable_storage=[session_dir, *config.readable],
            )
            # TODO: let storage.catalog choose the adapters and consolidators
            # installed here, rather than always installing ome-tiled's

            # SimpleTiledServer takes no adapters; the first map holds the
            # catalog's own, ahead of tiled's defaults
            server.catalog.context.adapters_by_mimetype.maps[0][OME_ZARR_MIMETYPE] = (
                OmeZarrAdapter
            )
            register_consolidator()
            self.path_provider.lock_base_dir(
                "the session's catalog reads files only from the readable "
                "directories it started with; choose the root with "
                "storage.base_dir before the session starts"
            )
            return server
        except Exception as e:  # noqa: BLE001 - a catalog that fails must not abort the app
            # a server that started and then failed to be set up is stopped,
            # since nothing else holds it
            if server is not None:
                server.close()
            # a dotted key: no declared component can have this name
            self._failed["storage.catalog"] = e
            logger.error(f"Failed to start the catalog: {e}")
            return None

    def _build_devices(self) -> None:
        """Build every declared device, skipping those that fail."""
        built_devices: dict[str, Device] = {}
        for name, device_comp in self._device_components.items():
            try:
                built_devices[name] = self._built[device_comp] = device_comp.build(
                    self._prefix_for(device_comp), self.path_provider
                )
                logger.debug(f"Device '{name}' built")
            except Exception as e:  # noqa: BLE001 - a missing device must not abort the app
                self._failed[name] = e
                logger.error(f"Failed to build device '{name}': {e}")
        self._built_devices = built_devices

    def _connect_devices(self) -> None:
        """Connect every built device declared with autoconnect, all at once.

        A device not connected within `CONNECT_TIMEOUT` is recorded as failed
        and dropped, like one failing to build, so no presenter gets a device
        that raises on its first read.
        """
        targets = {
            name: device
            for name, device in self._built_devices.items()
            if self._device_components[name].autoconnect
        }
        if not targets:
            return

        async def connect_all() -> list[BaseException | None]:
            return await asyncio.gather(
                *(
                    device.connect(timeout=CONNECT_TIMEOUT)
                    for device in targets.values()
                ),
                return_exceptions=True,
            )

        for name, result in zip(targets, run_coro(connect_all()), strict=True):
            if result is None:
                continue
            component = self._device_components[name]
            reason = self._connection_failure(component, result)
            self._failed[name] = ConnectionError(reason)
            del self._built[component]
            del self._built_devices[name]
            logger.error(f"Failed to connect device '{name}': {reason}")

    def _connection_failure(
        self, device: _DeviceComponent, error: BaseException
    ) -> str:
        """Return why *device* did not connect, naming its service."""
        # ophyd-async pads a NotConnectedError's message with whitespace
        detail = str(error).strip()
        service = self._services.get(device.service or "")
        if service is None:
            return detail
        how = "launched" if service.launched else "attached"
        return (
            f"service {service.name!r} ({how}) did not answer within "
            f"{CONNECT_TIMEOUT:g} s: {detail}"
        )

    def _prefix_for(self, device: _DeviceComponent) -> str | None:
        """Return the prefix *device*'s service gives it, or ``None`` if it names none.

        Raises
        ------
        LookupError
            If the device names a service that is not declared.
        RuntimeError
            If the device names a service that did not start.
        ValueError
            If the device names a service that gives no prefix.
        """
        if device.service is None:
            return None
        if device.service not in self._services:
            raise LookupError(f"service {device.service!r} is not declared")
        if device.service in self._failed_services:
            raise RuntimeError(f"service {device.service!r} was not started")
        prefix = self._services[device.service].prefix
        if not prefix:
            raise ValueError(f"service {device.service!r} gives no prefix")
        return prefix

    def _build_presenters(self) -> None:
        """Build every declared presenter against the built devices.

        A presenter that fails is skipped, like a failed device.
        """
        for comp_name, presenter_component in self._presenter_components.items():
            try:
                self._built[presenter_component] = presenter_component.build(
                    self._built_devices
                )
            except Exception as e:  # noqa: BLE001 - a missing presenter must not abort the app
                self._failed[comp_name] = e
                logger.error(f"Failed to build presenter '{comp_name}': {e}")

    def _build_views(self) -> None:
        """Build every declared view, skipping those that fail."""
        for comp_name, view_component in self._view_components.items():
            try:
                self._built[view_component] = view_component.build()
            except Exception as e:  # noqa: BLE001 - a missing view must not abort the app
                self._failed[comp_name] = e
                logger.error(f"Failed to build view '{comp_name}': {e}")

    def _register_providers(self) -> None:
        """Bind the session's path provider and catalog address, then each component's own."""
        self.virtual_container.provide(PATH_PROVIDER, self.path_provider)
        if self._catalog is not None:
            self.virtual_container.provide(CATALOG, CatalogAddress(self._catalog.uri))
        for instance in self._built_of(self._components).values():
            if isinstance(instance, IsProvider):
                instance.register_providers(self.virtual_container)

    def _apply_wiring(self) -> None:
        """Publish the built components by name, then connect them.

        Names come first, since `wire` and the ``wiring`` section resolve
        components by name. The session's path provider is published beside
        them as ``path_provider``, so a configuration file can feed it the
        plan name.
        """
        components = self._built_of(self._components)
        if PATH_PROVIDER_PORT in components:
            raise WiringError(
                f"component {PATH_PROVIDER_PORT!r} shadows the session path "
                "provider, which is published under that name; rename it"
            )
        self.virtual_container._set_components(
            {**components, PATH_PROVIDER_PORT: self.path_provider}
        )
        self.wire()
        self._apply_wiring_config()

    def _inject_dependencies(self) -> None:
        """Let each component taking dependencies receive them."""
        for instance in self._built_of(self._components).values():
            if isinstance(instance, IsInjectable):
                instance.inject_dependencies(self.virtual_container)

    def connect_devices(self, mock: bool = False) -> None:
        """Connect every device through ``ophyd-async``.

        Call after [`build`][redsun.containers.container.AppContainer.build],
        which already connected the devices declared with ``autoconnect``. This
        connects every device regardless, and a connected device returns at
        once. ``mock=True`` skips the hardware, for tests.

        Parameters
        ----------
        mock : bool
            Connect to mock backends, needing no hardware.

        Raises
        ------
        RuntimeError
            If called before [`build`][redsun.containers.container.AppContainer.build].
        """
        if not self._is_built:
            raise RuntimeError("Call build() before connect_devices()")

        async def _connect_all(mock: bool) -> None:
            await asyncio.gather(
                *[device.connect(mock=mock) for device in self._built_devices.values()]
            )

        run_coro(_connect_all(mock))

    def shutdown(self) -> None:
        """Undo the build, one phase at a time.

        The phases run in this order, each a method a subclass may override:

        1. ``_disconnect`` - undo the wiring.
        2. ``_shutdown_presenters`` - shut every presenter down.
        3. ``_close_catalog`` - stop the session's catalog.
        4. ``_shutdown_hooks`` - undo what the hook providers installed.
        5. ``_release_components`` - drop every built component.
        6. ``_destroy`` - end what dropping a reference does not end.

        Afterwards the container holds nothing it built, so ``devices``,
        ``presenters`` and ``views`` raise until the next ``build()``.

        Built or not, the container then stops its launched services, the last
        declared first, and closes the session's log file; the next ``build()``
        starts both again.
        """
        if self._is_built:
            self._disconnect()
            self._shutdown_presenters()
            # after the presenters, which may still be writing to it
            self._close_catalog()
            # after the components, which may still be using what a hook installed
            self._shutdown_hooks()
            self._destroy(self._release_components())

            self._is_built = False
            logger.info("Container shutdown complete")
        # started before the build, so stopped whether or not it completed, and
        # before the log file closes, since stopping logs how each service ended
        self._stop_services()
        self._close_session_log()

    def _stop_services(self) -> None:
        """Stop every service the container launched, the last declared first.

        A service failing to stop does not keep the others running. If any
        stopped, the process's Channel Access channels are closed, so a rebuilt
        container connects afresh.
        """
        stopped = False
        for name, service in reversed(self._services.items()):
            stopped = stopped or service.running
            try:
                service.stop()
            except Exception as e:  # noqa: BLE001 - one failed stop must not block the rest
                logger.error(f"Error stopping service '{name}': {e}")
        self._failed_services.clear()
        self._services_started = False
        if stopped:
            run_coro(close_channel_access())

    def _open_session_log(self) -> None:
        """Start writing this run's records to the session's log files.

        Application records go to one file and each launched service's to its
        own, so a noisy service rotates only its own file.
        """
        if self._session_log is not None:
            return
        session = self._config["session"]
        self._session_log = SessionFileHandler(session)
        add_handler(self._session_log)
        for name, service in self._services.items():
            if service.launched:
                handler = SessionFileHandler(session, name, self._session_log.run)
                add_handler(handler, name)
                self._service_logs[name] = handler

    def _close_session_log(self) -> None:
        """Stop writing to the session's log files, and close them."""
        for name, handler in self._service_logs.items():
            remove_handler(handler, name)
            handler.close()
        self._service_logs.clear()
        if self._session_log is not None:
            remove_handler(self._session_log)
            self._session_log.close()
            self._session_log = None

    def _disconnect(self) -> None:
        """Undo every connection and subscription the wiring made."""
        if self._virtual_container is not None:
            self._virtual_container.disconnect_all()

    def _shutdown_presenters(self) -> None:
        """Shut down every presenter implementing ``HasShutdown``.

        A presenter failing to shut down does not stop the others.
        """
        for name, presenter in self._built_of(self._presenter_components).items():
            if isinstance(presenter, HasShutdown):
                try:
                    presenter.shutdown()
                except Exception as e:  # noqa: BLE001 - one failed shutdown must not block the rest
                    logger.error(f"Error shutting down presenter '{name}': {e}")

    def _close_catalog(self) -> None:
        """Stop the session's catalog, if one was started."""
        if self._catalog is None:
            return
        try:
            self._catalog.close()
        except Exception as e:  # noqa: BLE001 - a failed close must not block the shutdown
            logger.error(f"Error closing the catalog: {e}")
        self._catalog = None

    def _release_components(self) -> Sequence[object]:
        """Drop every built component, and return what was dropped.

        The virtual container forgets them too, so nothing the framework owns
        still references them.
        """
        if self._virtual_container is not None:
            self._virtual_container._clear_components()
        released = list(self._built.values())
        self._built.clear()
        self._failed.clear()
        self._built_devices = {}
        return released

    def _destroy(self, components: Sequence[object]) -> None:
        """Destroy the components the container has just released.

        A container bound to no toolkit can only drop the last reference, which
        does not end objects a toolkit owns outside Python. Such a toolkit
        overrides this to end them.

        *components* are already released: neither this container nor the
        virtual container holds them.
        """

    def run(self) -> None:
        """Build the container if needed, then start the application."""
        if not self._is_built:
            self.build()

        frontend = self._config.get("frontend", "pyqt")
        logger.info(f"Starting application with frontend: {frontend}")

    @classmethod
    def from_config(
        cls, config_path: str, *, log_level: int | str | None = None
    ) -> AppContainer:
        """Build a container from a YAML configuration file.

        *log_level* is passed to the container it builds.
        """
        config, plugin_types, services = cls._load_configuration(config_path)

        namespace: dict[str, Any] = {
            name: _ServiceComponent(name, **kwargs) for name, kwargs in services.items()
        }

        declared: tuple[tuple[PLUGIN_GROUPS, _ComponentFactory], ...] = (
            ("devices", _DeviceComponent),
            ("presenters", _PresenterComponent),
            ("views", _ViewComponent),
        )
        for group, component in declared:
            section: dict[str, Any] = config.get(group, {})
            for name, plugin_class in plugin_types[group].items():
                cfg_kwargs = {
                    k: v
                    for k, v in section.get(name, {}).items()
                    if k not in _PLUGIN_META_KEYS
                }
                namespace[name] = component(plugin_class, name, **cfg_kwargs)

        frontend = config.get("frontend", "pyqt")
        base_class = _resolve_frontend_container(frontend)

        DynamicApp: type[AppContainer] = type("DynamicApp", (base_class,), namespace)

        instance = DynamicApp(
            session=config.get("session", "Redsun"),
            frontend=frontend,
            log_level=log_level,
        )
        if "storage" in config:
            instance._config["storage"] = config["storage"]
        if "wiring" in config:
            instance._config["wiring"] = config["wiring"]
        if "hooks" in config:
            instance._config["hooks"] = config["hooks"]

        return instance

    @classmethod
    def _load_configuration(
        cls, config_path: str
    ) -> tuple[dict[str, Any], _PluginTypeDict, dict[str, dict[str, Any]]]:
        """Load configuration, discover plugin classes and resolve services.

        Services are returned as each declaration's keyword arguments. A
        service naming a plugin takes its module and readiness line from the
        plugin's manifest, overridden by the session file.
        """
        with open(config_path, "r") as f:
            config: dict[str, Any] = yaml.safe_load(f)

        plugin_types: _PluginTypeDict = {"devices": {}, "presenters": {}, "views": {}}
        available_manifests = entry_points(group="redsun.plugins")

        services: dict[str, dict[str, Any]] = {}
        for name, entry in (config.get("services") or {}).items():
            kwargs = {k: v for k, v in entry.items() if k not in _PLUGIN_META_KEYS}
            if "plugin_name" in entry:
                launched = cls._manifest_item(
                    entry["plugin_name"],
                    "services",
                    entry["plugin_id"],
                    available_manifests,
                )
                if not isinstance(launched, dict):
                    # _manifest_item already logged why it returned None
                    if launched is not None:
                        logger.error(
                            'Plugin "%s" lists service "%s" as %r, not a mapping.',
                            entry["plugin_name"],
                            entry["plugin_id"],
                            launched,
                        )
                    continue
                kwargs = {**launched, **kwargs}
            services[name] = kwargs

        groups: list[PLUGIN_GROUPS] = ["devices", "presenters", "views"]

        for group in groups:
            if group not in config:
                logger.debug(
                    "Group %s not found in the configuration file. Skipping", group
                )
                continue
            loaded = cls._load_plugins(
                group_cfg=config[group],
                group=group,
                available_manifests=available_manifests,
            )
            for name, plugin_cls in loaded:
                plugin_types[group][name] = plugin_cls  # type: ignore[assignment]

        return config, plugin_types, services

    @classmethod
    def _manifest_item(
        cls,
        plugin_name: str,
        group: str,
        plugin_id: str,
        available_manifests: EntryPoints,
    ) -> Any:
        """Return what *plugin_name*'s manifest lists as *plugin_id* under *group*.

        ``None``, with the reason logged, if the plugin is not installed or
        lacks the entry.
        """
        plugin = next(
            (entry for entry in available_manifests if entry.name == plugin_name), None
        )
        if plugin is None:
            logger.error('Plugin "%s" not found in the installed plugins.', plugin_name)
            return None

        pkg_manifest = files(plugin.name.replace("-", "_")) / plugin.value
        with as_file(pkg_manifest) as manifest_path, open(manifest_path) as f:
            manifest: dict[str, ManifestItems] = yaml.safe_load(f)

        if group not in manifest:
            logger.error(
                'Plugin "%s" manifest does not contain group "%s".', plugin_name, group
            )
            return None
        items = manifest[group]
        if plugin_id not in items:
            logger.error(
                'Plugin "%s" does not contain the id "%s".', plugin_name, plugin_id
            )
            return None
        return items[plugin_id]

    @classmethod
    def _load_plugins(
        cls,
        *,
        group_cfg: dict[str, Any],
        group: PLUGIN_GROUPS,
        available_manifests: EntryPoints,
    ) -> list[tuple[str, PluginType]]:
        """Load a group's plugin classes from their manifests."""
        plugins: list[tuple[str, PluginType]] = []

        for name, info in group_cfg.items():
            plugin_id: str = info["plugin_id"]
            class_path = cls._manifest_item(
                info["plugin_name"], group, plugin_id, available_manifests
            )
            if class_path is None:
                continue
            try:
                class_item_module, class_item_type = class_path.split(":")
                imported_class = getattr(
                    import_module(class_item_module), class_item_type
                )
            except (KeyError, ValueError):
                logger.error(
                    'Plugin id "%s" of "%s" has invalid class path "%s". Skipping.',
                    plugin_id,
                    name,
                    class_path,
                )
                continue

            if not _check_plugin_protocol(imported_class, group):
                logger.error(
                    "%s cannot be loaded as a plugin in group %r: it %s.",
                    imported_class,
                    group,
                    _PLUGIN_EXPECTATIONS[group],
                )
                continue

            plugins.append((name, imported_class))

        return plugins

BUILD_STEPS class-attribute

BUILD_STEPS: tuple[str, ...] = (
    "services",
    "virtual container",
    "devices",
    "connect",
    "presenters",
    "views",
    "providers",
    "wiring",
    "injection",
)

The steps build announces, in order.

Each is reported when it starts, so a progress display can size itself from this tuple.

config property

config: AppConfig

Return the application configuration.

devices property

devices: dict[str, Device]

Return the built devices.

presenters property

presenters: dict[str, PPresenter]

Return the built presenters.

views property

views: dict[str, PView]

Return the built views.

services property

services: dict[str, Service]

Return the container's services, started or not.

storage property

storage: StorageConfig

Return the session's storage configuration.

path_provider property

path_provider: SessionPathProvider

Return the session's path provider, shared by every device taking one.

virtual_container property

virtual_container: VirtualContainer

Return the virtual container.

is_built property

is_built: bool

Return whether the container has been built.

wire

wire() -> None

Connect the signals and slots of built components.

Override it to declare an application's connections. Every component is built when this runs, available as the attribute it was declared under:

class MyApp(AppContainer):
    det_ctrl = declare_presenter(DetectorPresenter)
    img_widget = declare_view(ImageView)

    def wire(self) -> None:
        self.connect(self.det_ctrl.sig_new_data, self.img_widget.update_layers)

Connects nothing by default.

Source code in src/redsun/containers/container.py
def wire(self) -> None:
    """Connect the signals and slots of built components.

    Override it to declare an application's connections. Every component is
    built when this runs, available as the attribute it was declared under:

    ```python
    class MyApp(AppContainer):
        det_ctrl = declare_presenter(DetectorPresenter)
        img_widget = declare_view(ImageView)

        def wire(self) -> None:
            self.connect(self.det_ctrl.sig_new_data, self.img_widget.update_layers)
    ```

    Connects nothing by default.
    """

connect

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

Connect a signal to a slot, recording the link for teardown.

Returns None without connecting when an end belongs to a component that failed to build: the link is logged at WARNING and wire continues. Any other wrong port raises.

See VirtualContainer.connect.

Source code in src/redsun/containers/container.py
def connect(
    self,
    signal: SignalInstance,
    slot: Callable[..., Any],
    *,
    thread: SlotThread = None,
) -> Connection | None:
    """Connect a signal to a slot, recording the link for teardown.

    Returns ``None`` without connecting when an end belongs to a component
    that failed to build: the link is logged at ``WARNING`` and `wire`
    continues. Any other wrong port raises.

    See [`VirtualContainer.connect`][redsun.virtual.VirtualContainer.connect].
    """
    ends: tuple[object, object] = (signal, slot)
    absent = {end.component for end in ends if isinstance(end, _NotBuilt)}
    if absent:
        named = ", ".join(repr(name) for name in sorted(absent))
        logger.warning(
            f"Not connecting {self._end_path(signal)} -> "
            f"{self._end_path(slot)}: {named} not built"
        )
        return None
    return self.virtual_container.connect(signal, slot, thread=thread)

build

build() -> Self

Build every component in dependency order.

The order is fixed, and each step is announced as it starts:

  1. Services, through start_services
  2. VirtualContainer
  3. Devices
  4. Connect, every device declared with autoconnect true
  5. Presenters
  6. Views
  7. Providers, registered into the VirtualContainer
  8. Wiring, connecting the signals and slots of built components
  9. Remaining dependency injection

A build that raises stops the services first, so no process it launched outlives it.

Source code in src/redsun/containers/container.py
def build(self) -> Self:
    """Build every component in dependency order.

    The order is fixed, and each step is announced as it starts:

    1. Services, through `start_services`
    2. VirtualContainer
    3. Devices
    4. Connect, every device declared with ``autoconnect`` true
    5. Presenters
    6. Views
    7. Providers, registered into the VirtualContainer
    8. Wiring, connecting the signals and slots of built components
    9. Remaining dependency injection

    A build that raises stops the services first, so no process it launched
    outlives it.
    """
    if self._is_built:
        logger.warning("Container already built, skipping rebuild")
        return self

    get_shared_loop()
    self._open_session_log()

    logger.info("Building application container...")

    # resolved even by a container that calls no hook point of its own, so
    # that a malformed hooks section is refused wherever it is built
    self._ensure_hooks()

    try:
        self._report("services")
        self.start_services()
        self._report("virtual container")
        self._create_virtual_container()
        self._report("devices")
        self._build_devices()
        self._report("connect")
        self._connect_devices()
        self._report("presenters")
        self._build_presenters()
        self._report("views")
        self._build_views()
        self._report("providers")
        self._register_providers()
        self._report("wiring")
        self._apply_wiring()
        self._report("injection")
        self._inject_dependencies()
    except BaseException:
        self._close_catalog()
        self._stop_services()
        raise

    self._is_built = True
    summary = self._summarise_build()
    if self._failed:
        logger.warning(summary)
    else:
        logger.info(summary)

    return self

start_services

start_services() -> None

Start every service the container launches, and attach to the rest.

build calls this too. Only the first call until shutdown does anything. A service that fails to start is logged, and the build skips every device naming it; the rest of the session runs.

Source code in src/redsun/containers/container.py
def start_services(self) -> None:
    """Start every service the container launches, and attach to the rest.

    `build` calls this too. Only the first call until `shutdown` does
    anything. A service that fails to start is logged, and the build skips
    every device naming it; the rest of the session runs.
    """
    if self._services_started:
        return
    self._services_started = True
    if not self._services:
        return
    for name, service in self._services.items():
        try:
            service.start()
        except Exception as e:  # noqa: BLE001 - a missing service must not abort the app
            self._failed_services[name] = e
            logger.error(f"Failed to start service '{name}': {e}")
    summary = (
        f"Services started: {len(self._services) - len(self._failed_services)}"
        f"/{len(self._services)}"
    )
    if not self._failed_services:
        logger.info(summary)
        return
    failed = ", ".join(
        f"{name} ({reason})" for name, reason in self._failed_services.items()
    )
    logger.warning(f"{summary}\nNot started: {failed}")

connect_devices

connect_devices(mock: bool = False) -> None

Connect every device through ophyd-async.

Call after build, which already connected the devices declared with autoconnect. This connects every device regardless, and a connected device returns at once. mock=True skips the hardware, for tests.

Parameters:

Name Type Description Default
mock bool

Connect to mock backends, needing no hardware.

False

Raises:

Type Description
RuntimeError

If called before build.

Source code in src/redsun/containers/container.py
def connect_devices(self, mock: bool = False) -> None:
    """Connect every device through ``ophyd-async``.

    Call after [`build`][redsun.containers.container.AppContainer.build],
    which already connected the devices declared with ``autoconnect``. This
    connects every device regardless, and a connected device returns at
    once. ``mock=True`` skips the hardware, for tests.

    Parameters
    ----------
    mock : bool
        Connect to mock backends, needing no hardware.

    Raises
    ------
    RuntimeError
        If called before [`build`][redsun.containers.container.AppContainer.build].
    """
    if not self._is_built:
        raise RuntimeError("Call build() before connect_devices()")

    async def _connect_all(mock: bool) -> None:
        await asyncio.gather(
            *[device.connect(mock=mock) for device in self._built_devices.values()]
        )

    run_coro(_connect_all(mock))

shutdown

shutdown() -> None

Undo the build, one phase at a time.

The phases run in this order, each a method a subclass may override:

  1. _disconnect - undo the wiring.
  2. _shutdown_presenters - shut every presenter down.
  3. _close_catalog - stop the session's catalog.
  4. _shutdown_hooks - undo what the hook providers installed.
  5. _release_components - drop every built component.
  6. _destroy - end what dropping a reference does not end.

Afterwards the container holds nothing it built, so devices, presenters and views raise until the next build().

Built or not, the container then stops its launched services, the last declared first, and closes the session's log file; the next build() starts both again.

Source code in src/redsun/containers/container.py
def shutdown(self) -> None:
    """Undo the build, one phase at a time.

    The phases run in this order, each a method a subclass may override:

    1. ``_disconnect`` - undo the wiring.
    2. ``_shutdown_presenters`` - shut every presenter down.
    3. ``_close_catalog`` - stop the session's catalog.
    4. ``_shutdown_hooks`` - undo what the hook providers installed.
    5. ``_release_components`` - drop every built component.
    6. ``_destroy`` - end what dropping a reference does not end.

    Afterwards the container holds nothing it built, so ``devices``,
    ``presenters`` and ``views`` raise until the next ``build()``.

    Built or not, the container then stops its launched services, the last
    declared first, and closes the session's log file; the next ``build()``
    starts both again.
    """
    if self._is_built:
        self._disconnect()
        self._shutdown_presenters()
        # after the presenters, which may still be writing to it
        self._close_catalog()
        # after the components, which may still be using what a hook installed
        self._shutdown_hooks()
        self._destroy(self._release_components())

        self._is_built = False
        logger.info("Container shutdown complete")
    # started before the build, so stopped whether or not it completed, and
    # before the log file closes, since stopping logs how each service ended
    self._stop_services()
    self._close_session_log()

run

run() -> None

Build the container if needed, then start the application.

Source code in src/redsun/containers/container.py
def run(self) -> None:
    """Build the container if needed, then start the application."""
    if not self._is_built:
        self.build()

    frontend = self._config.get("frontend", "pyqt")
    logger.info(f"Starting application with frontend: {frontend}")

from_config classmethod

from_config(
    config_path: str, *, log_level: int | str | None = None
) -> AppContainer

Build a container from a YAML configuration file.

log_level is passed to the container it builds.

Source code in src/redsun/containers/container.py
@classmethod
def from_config(
    cls, config_path: str, *, log_level: int | str | None = None
) -> AppContainer:
    """Build a container from a YAML configuration file.

    *log_level* is passed to the container it builds.
    """
    config, plugin_types, services = cls._load_configuration(config_path)

    namespace: dict[str, Any] = {
        name: _ServiceComponent(name, **kwargs) for name, kwargs in services.items()
    }

    declared: tuple[tuple[PLUGIN_GROUPS, _ComponentFactory], ...] = (
        ("devices", _DeviceComponent),
        ("presenters", _PresenterComponent),
        ("views", _ViewComponent),
    )
    for group, component in declared:
        section: dict[str, Any] = config.get(group, {})
        for name, plugin_class in plugin_types[group].items():
            cfg_kwargs = {
                k: v
                for k, v in section.get(name, {}).items()
                if k not in _PLUGIN_META_KEYS
            }
            namespace[name] = component(plugin_class, name, **cfg_kwargs)

    frontend = config.get("frontend", "pyqt")
    base_class = _resolve_frontend_container(frontend)

    DynamicApp: type[AppContainer] = type("DynamicApp", (base_class,), namespace)

    instance = DynamicApp(
        session=config.get("session", "Redsun"),
        frontend=frontend,
        log_level=log_level,
    )
    if "storage" in config:
        instance._config["storage"] = config["storage"]
    if "wiring" in config:
        instance._config["wiring"] = config["wiring"]
    if "hooks" in config:
        instance._config["hooks"] = config["hooks"]

    return instance

AppConfig

Bases: RedSunConfig

Configuration of an application container.

RedSunConfig plus the component sections, which the container reads and does not pass to components.

Attributes:

Name Type Description
schema_version Required[float]
frontend Required[str]
session NotRequired[str]
metadata NotRequired[dict[str, Any]]
services ForwardRef('NotRequired[dict[str, Any]]', module='redsun.containers._config')
devices ForwardRef('NotRequired[dict[str, Any]]', module='redsun.containers._config')
presenters ForwardRef('NotRequired[dict[str, Any]]', module='redsun.containers._config')
views ForwardRef('NotRequired[dict[str, Any]]', module='redsun.containers._config')
storage ForwardRef('NotRequired[dict[str, Any] | None]', module='redsun.containers._config')
wiring ForwardRef('NotRequired[list[dict[str, str]]]', module='redsun.containers._config')
hooks ForwardRef('NotRequired[dict[str, dict[str, Any]]]', module='redsun.containers._config')
Source code in src/redsun/containers/_config.py
class AppConfig(RedSunConfig, total=False):
    """Configuration of an application container.

    [`RedSunConfig`][redsun.virtual.RedSunConfig] plus the component sections,
    which the container reads and does not pass to components.
    """

    services: NotRequired[dict[str, Any]]
    devices: NotRequired[dict[str, Any]]
    presenters: NotRequired[dict[str, Any]]
    views: NotRequired[dict[str, Any]]
    storage: NotRequired[dict[str, Any] | None]
    wiring: NotRequired[list[dict[str, str]]]
    hooks: NotRequired[dict[str, dict[str, Any]]]

StorageConfig dataclass

Where a session writes, and whether it keeps a catalog of its runs.

Parameters:

Name Type Description Default

Attributes:

Name Type Description
base_dir Path | None
max_digits int
catalog CatalogConfig | None
Source code in src/redsun/containers/_config.py
@dataclass(frozen=True, slots=True)
class StorageConfig:
    """Where a session writes, and whether it keeps a catalog of its runs.

    Parameters
    ----------
    base_dir : Path | None
        Root the session writes under. `None` is the user data directory.
    max_digits : int
        Width of the counter in file names.
    catalog : CatalogConfig | None
        The session's catalog, needing the ``tiled`` extra; `None` for none.
    """

    base_dir: Path | None = None
    max_digits: int = 5
    catalog: CatalogConfig | None = None

    @classmethod
    def from_mapping(cls, section: Mapping[str, Any] | None) -> StorageConfig:
        """Read a session file's `storage` section. An empty `catalog` key is a catalog.

        Raises
        ------
        TypeError
            If the section, or its `catalog` key, is not a mapping, or
            `readable` is not a list.
        ValueError
            If either names a key it has no place for.
        """
        section = mapping_of(section, "storage")
        refuse_unknown(section, "storage", ("base_dir", "max_digits", "catalog"))
        catalog = None
        if "catalog" in section:
            entry = mapping_of(section["catalog"], "storage.catalog")
            refuse_unknown(entry, "storage.catalog", ("readable",))
            readable = entry.get("readable") or []
            if not isinstance(readable, list):
                raise TypeError(
                    "'storage.catalog.readable' must be a list of directories, "
                    f"got {type(readable).__name__}"
                )
            catalog = CatalogConfig(
                readable=tuple(Path(path).expanduser() for path in readable)
            )
        base_dir = section.get("base_dir")
        return cls(
            base_dir=Path(base_dir).expanduser() if base_dir else None,
            max_digits=section.get("max_digits", 5),
            catalog=catalog,
        )

from_mapping classmethod

from_mapping(
    section: Mapping[str, Any] | None,
) -> StorageConfig

Read a session file's storage section. An empty catalog key is a catalog.

Raises:

Type Description
TypeError

If the section, or its catalog key, is not a mapping, or readable is not a list.

ValueError

If either names a key it has no place for.

Source code in src/redsun/containers/_config.py
@classmethod
def from_mapping(cls, section: Mapping[str, Any] | None) -> StorageConfig:
    """Read a session file's `storage` section. An empty `catalog` key is a catalog.

    Raises
    ------
    TypeError
        If the section, or its `catalog` key, is not a mapping, or
        `readable` is not a list.
    ValueError
        If either names a key it has no place for.
    """
    section = mapping_of(section, "storage")
    refuse_unknown(section, "storage", ("base_dir", "max_digits", "catalog"))
    catalog = None
    if "catalog" in section:
        entry = mapping_of(section["catalog"], "storage.catalog")
        refuse_unknown(entry, "storage.catalog", ("readable",))
        readable = entry.get("readable") or []
        if not isinstance(readable, list):
            raise TypeError(
                "'storage.catalog.readable' must be a list of directories, "
                f"got {type(readable).__name__}"
            )
        catalog = CatalogConfig(
            readable=tuple(Path(path).expanduser() for path in readable)
        )
    base_dir = section.get("base_dir")
    return cls(
        base_dir=Path(base_dir).expanduser() if base_dir else None,
        max_digits=section.get("max_digits", 5),
        catalog=catalog,
    )

CatalogConfig dataclass

The catalog a session keeps in <base_dir>/<session>/catalog.

Parameters:

Name Type Description Default

Attributes:

Name Type Description
readable tuple[Path, ...]

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

Source code in src/redsun/containers/_config.py
@dataclass(frozen=True, slots=True)
class CatalogConfig:
    """The catalog a session keeps in `<base_dir>/<session>/catalog`.

    Parameters
    ----------
    readable : tuple[Path, ...]
        Directories the catalog may read from besides the session's own.
    """

    readable: tuple[Path, ...] = field(default_factory=tuple)

HookError

Bases: RuntimeError

A hooks configuration entry cannot be turned into a provider.

Source code in src/redsun/containers/_hooks.py
class HookError(RuntimeError):
    """A ``hooks`` configuration entry cannot be turned into a provider."""

CreatesApplication

Bases: Protocol[AppT_co]

Supplies the toolkit's application object instead of the container.

Source code in src/redsun/containers/_hooks.py
@runtime_checkable
class CreatesApplication(Protocol[AppT_co]):
    """Supplies the toolkit's application object instead of the container."""

    @abstractmethod
    def create_application(self, argv: list[str]) -> AppT_co:
        """Return the application object the session runs on."""
        ...

create_application abstractmethod

create_application(argv: list[str]) -> AppT_co

Return the application object the session runs on.

Source code in src/redsun/containers/_hooks.py
@abstractmethod
def create_application(self, argv: list[str]) -> AppT_co:
    """Return the application object the session runs on."""
    ...

ConfiguresApplication

Bases: Protocol[AppT_contra]

Adjusts the application before any view is constructed.

Source code in src/redsun/containers/_hooks.py
@runtime_checkable
class ConfiguresApplication(Protocol[AppT_contra]):
    """Adjusts the application before any view is constructed."""

    @abstractmethod
    def configure_application(self, app: AppT_contra) -> None:
        """Act on *app*, which every view is about to be built against."""
        ...

configure_application abstractmethod

configure_application(app: AppT_contra) -> None

Act on app, which every view is about to be built against.

Source code in src/redsun/containers/_hooks.py
@abstractmethod
def configure_application(self, app: AppT_contra) -> None:
    """Act on *app*, which every view is about to be built against."""
    ...

ConfiguresMainView

Bases: Protocol[ViewT_contra]

Adjusts the main window after it is built and before it is shown.

Source code in src/redsun/containers/_hooks.py
@runtime_checkable
class ConfiguresMainView(Protocol[ViewT_contra]):
    """Adjusts the main window after it is built and before it is shown."""

    @abstractmethod
    def configure_main_view(self, view: ViewT_contra) -> None:
        """Act on *view*, the window the session is about to show."""
        ...

configure_main_view abstractmethod

configure_main_view(view: ViewT_contra) -> None

Act on view, the window the session is about to show.

Source code in src/redsun/containers/_hooks.py
@abstractmethod
def configure_main_view(self, view: ViewT_contra) -> None:
    """Act on *view*, the window the session is about to show."""
    ...

WrapsBuild

Bases: Protocol[AppT_contra]

Surrounds the build, from before the first component to after the window.

The one hook point that is a span, not a moment: a splash screen appears before anything is built, reports progress, and closes once the window is on screen.

Source code in src/redsun/containers/_hooks.py
@runtime_checkable
class WrapsBuild(Protocol[AppT_contra]):
    """Surrounds the build, from before the first component to after the window.

    The one hook point that is a span, not a moment: a splash screen appears
    before anything is built, reports progress, and closes once the window is
    on screen.
    """

    @abstractmethod
    def during_build(
        self, app: AppT_contra
    ) -> AbstractContextManager[Callable[[str], None]]:
        """Return a context manager open for the whole build.

        What it yields is called with the name of each step as it starts.
        """
        ...

during_build abstractmethod

during_build(
    app: AppT_contra,
) -> AbstractContextManager[Callable[[str], None]]

Return a context manager open for the whole build.

What it yields is called with the name of each step as it starts.

Source code in src/redsun/containers/_hooks.py
@abstractmethod
def during_build(
    self, app: AppT_contra
) -> AbstractContextManager[Callable[[str], None]]:
    """Return a context manager open for the whole build.

    What it yields is called with the name of each step as it starts.
    """
    ...

QtAppContainer

Bases: AppContainer

Application container for a Qt frontend.

Runs the Qt lifecycle: creating the QApplication, building the container and the QtMainView, connecting the signal queue, and app.exec().

Parameters:

Name Type Description Default
**config Any

Options passed to AppContainer.__init__.

{}
Source code in src/redsun/containers/qt/_container.py
class QtAppContainer(AppContainer):
    """Application container for a Qt frontend.

    Runs the Qt lifecycle: creating the ``QApplication``, building the
    container and the ``QtMainView``, connecting the signal queue, and
    ``app.exec()``.

    Parameters
    ----------
    **config : Any
        Options passed to `AppContainer.__init__`.
    """

    __slots__ = ("_main_view", "_qt_app")

    _hook_keys: ClassVar[Mapping[str, type]] = {
        "create_application": CreatesApplication,
        "configure_application": ConfiguresApplication,
        "during_build": WrapsBuild,
        "configure_main_view": ConfiguresMainView,
    }

    def __init__(self, **config: Any) -> None:
        super().__init__(**config)
        self._qt_app: QApplication | None = None
        self._main_view: QtMainView | None = None

    @property
    def main_view(self) -> QtMainView:
        """Return the main Qt window.

        Raises
        ------
        RuntimeError
            If the application has not been run yet.
        """
        if self._main_view is None:
            raise RuntimeError("Main view not built. Call run() first.")
        return self._main_view

    def _ensure_application(self) -> QApplication:
        """Return the ``QApplication``, creating one if none is running yet.

        No widget can be built without one, so every path reaching a view calls
        this first. A running application is used as is; only when there is
        none is a `redsun.qt.QtCreatesApplication` hook called.
        """
        if self._qt_app is not None:
            return self._qt_app
        # resolved even when an application is already running, so that a
        # malformed hooks section fails the same way under a test suite that
        # owns an application and a desktop launch that does not
        creator = self._ensure_hooks().get("create_application")
        running = QApplication.instance()
        if running is not None:
            app = cast("QApplication", running)
        elif isinstance(creator, CreatesApplication):
            app = cast("QApplication", creator.create_application(sys.argv))
        else:
            app = QApplication(sys.argv)
        self._qt_app = app
        return app

    def _ensure_main_view(self) -> QtMainView:
        """Return the main window, building it from the built views if needed.

        Every `redsun.qt.QtConfiguresMainView` hook runs on it once, when it is
        created and before it is shown.
        """
        if self._main_view is None:
            self._main_view = QtMainView(
                virtual_container=self.virtual_container,
                session_name=self._config.get("session", "Redsun"),
                views=cast("dict[str, QtView]", self.views),
            )
            hook = self._ensure_hooks().get("configure_main_view")
            if isinstance(hook, ConfiguresMainView):
                hook.configure_main_view(self._main_view)
        return self._main_view

    def build(self) -> QtAppContainer:
        """Ensure a ``QApplication`` and an async backend exist, then build.

        Without a running ``QApplication``, as when ``build()`` is called before
        ``run()``, one is created here so views can construct widgets.

        Every `redsun.qt.QtConfiguresApplication` hook runs on the application
        before any view is built, so views are built with its stylesheet
        already set.
        """
        app = self._ensure_application()
        # coroutine slots resolve a backend when they are connected, which
        # happens during the dependency injection phase of super().build()
        set_async_backend()
        hook = self._ensure_hooks().get("configure_application")
        if isinstance(hook, ConfiguresApplication):
            hook.configure_application(app)
        super().build()
        return self

    def shutdown(self) -> None:
        """Shut components down, then the async backend."""
        super().shutdown()
        clear_async_backend()

    def _destroy(self, components: Sequence[object]) -> None:
        """Destroy the widgets among *components*, and the main window.

        A ``QWidget`` owned by C++ outlives its last Python reference, so only
        ``deleteLater`` ends it. Closing it first gives a widget holding
        resources, such as an embedded canvas, its ``closeEvent``.

        The window goes after the views it docks, and exists only when the
        session started through ``run``: a container built but never run holds
        its views as top-level widgets without a window.

        A reference kept from before shutdown wraps a destroyed widget and
        raises ``RuntimeError`` when used.

        Emissions still queued for a slot with a thread affinity are delivered
        first, while the widgets can receive them.
        """
        self._drain_queued_emissions()
        for component in components:
            if isinstance(component, QWidget):
                component.close()
                component.deleteLater()
        if self._main_view is not None:
            self._main_view.close()
            self._main_view.deleteLater()
            # the property reports an unbuilt window rather than handing back a
            # wrapper whose widget is gone, and a rebuild makes a new one
            self._main_view = None
        if self._qt_app is not None:
            # deleteLater only posts the deletion, and a container shut down
            # without an event loop running would never reach the pass that
            # carries it out
            self._qt_app.sendPostedEvents(None, QEvent.Type.DeferredDelete)

    def _drain_queued_emissions(self) -> None:
        """Stop emitting from the ``psygnal`` queue and emit what is left in it."""
        stop_emitting_from_queue()
        try:
            emit_queued()
        except Exception as e:  # noqa: BLE001 - a failed delivery must not block teardown
            logger.error(f"Error draining queued emissions: {e}")

    def _during_build(
        self, app: QApplication
    ) -> AbstractContextManager[Callable[[str], None]]:
        """Return the span a `redsun.qt.QtWrapsBuild` hook wraps the build in.

        Without a hook, a context manager yielding a reporter that does nothing,
        so `run` has one path.
        """
        hook = self._ensure_hooks().get("during_build")
        if isinstance(hook, WrapsBuild):
            return hook.during_build(app)
        return nullcontext(_silent)

    def run(self) -> NoReturn:
        """Build and launch the Qt application.

        The build, the window and its first paint happen inside the span a
        `redsun.qt.QtWrapsBuild` hook opens, so a splash screen covers all three
        and closes once the window is up, or when the build fails.
        """
        qt_app = self._ensure_application()

        with self._during_build(qt_app) as report:
            self._report = report
            try:
                if not self.is_built:
                    self.build()

                main_view = self._ensure_main_view()

                qt_app.aboutToQuit.connect(self.shutdown)
                start_emitting_from_queue()

                main_view.show()
                # `show` only schedules the first paint, so without this the
                # span would close over a window that has not drawn yet and a
                # splash screen would uncover an empty desktop
                qt_app.processEvents()
            finally:
                self._report = _silent

        sys.exit(qt_app.exec())

main_view property

main_view: QtMainView

Return the main Qt window.

Raises:

Type Description
RuntimeError

If the application has not been run yet.

build

build() -> QtAppContainer

Ensure a QApplication and an async backend exist, then build.

Without a running QApplication, as when build() is called before run(), one is created here so views can construct widgets.

Every redsun.qt.QtConfiguresApplication hook runs on the application before any view is built, so views are built with its stylesheet already set.

Source code in src/redsun/containers/qt/_container.py
def build(self) -> QtAppContainer:
    """Ensure a ``QApplication`` and an async backend exist, then build.

    Without a running ``QApplication``, as when ``build()`` is called before
    ``run()``, one is created here so views can construct widgets.

    Every `redsun.qt.QtConfiguresApplication` hook runs on the application
    before any view is built, so views are built with its stylesheet
    already set.
    """
    app = self._ensure_application()
    # coroutine slots resolve a backend when they are connected, which
    # happens during the dependency injection phase of super().build()
    set_async_backend()
    hook = self._ensure_hooks().get("configure_application")
    if isinstance(hook, ConfiguresApplication):
        hook.configure_application(app)
    super().build()
    return self

shutdown

shutdown() -> None

Shut components down, then the async backend.

Source code in src/redsun/containers/qt/_container.py
def shutdown(self) -> None:
    """Shut components down, then the async backend."""
    super().shutdown()
    clear_async_backend()

run

run() -> NoReturn

Build and launch the Qt application.

The build, the window and its first paint happen inside the span a redsun.qt.QtWrapsBuild hook opens, so a splash screen covers all three and closes once the window is up, or when the build fails.

Source code in src/redsun/containers/qt/_container.py
def run(self) -> NoReturn:
    """Build and launch the Qt application.

    The build, the window and its first paint happen inside the span a
    `redsun.qt.QtWrapsBuild` hook opens, so a splash screen covers all three
    and closes once the window is up, or when the build fails.
    """
    qt_app = self._ensure_application()

    with self._during_build(qt_app) as report:
        self._report = report
        try:
            if not self.is_built:
                self.build()

            main_view = self._ensure_main_view()

            qt_app.aboutToQuit.connect(self.shutdown)
            start_emitting_from_queue()

            main_view.show()
            # `show` only schedules the first paint, so without this the
            # span would close over a window that has not drawn yet and a
            # splash screen would uncover an empty desktop
            qt_app.processEvents()
        finally:
            self._report = _silent

    sys.exit(qt_app.exec())

_hooks

The hook protocols, parameterised with the Qt object each point receives.

QtCreatesApplication module-attribute

QtCreatesApplication: TypeAlias = CreatesApplication[
    "QApplication"
]

Supplies the session's QApplication.

Called only when no QApplication exists yet, so it sets application identity: the class, argv, attributes settable only before construction. A theme belongs in QtConfiguresApplication, which runs whether or not the application was created here. At most one hook may claim this point.

QtConfiguresApplication module-attribute

QtConfiguresApplication: TypeAlias = ConfiguresApplication[
    "QApplication"
]

Adjusts the QApplication before any view is constructed.

The place for an application-wide style, stylesheet, font or palette.

QtWrapsBuild module-attribute

QtWrapsBuild: TypeAlias = WrapsBuild['QApplication']

Wraps the build, from before the first component until the window shows.

The place for a splash screen. The returned context manager is entered before anything is built and exited once the window is on screen, or when the build fails; what it yields is called with each step's name.

QtConfiguresMainView module-attribute

QtConfiguresMainView: TypeAlias = ConfiguresMainView[
    "QMainWindow"
]

Adjusts the main window after it is built and before it is shown.

Typed as QMainWindow, not the container's own window class, so hooks depend on the toolkit only.