Add docstrings for all test functions and test helper functions
Marco Ricci

Marco Ricci commited on 2025-01-24 23:03:43
Zeige 10 geänderte Dateien mit 1246 Einfügungen und 14 Löschungen.


These docstrings aim to provide intent and context to the test function.
... ...
@@ -40,25 +40,85 @@ if TYPE_CHECKING:
40 40
 
41 41
 
42 42
 class SSHTestKey(NamedTuple):
43
+    """An SSH test key.
44
+
45
+    Attributes:
46
+        public_key:
47
+            The SSH public key string, as used e.g. by OpenSSH's
48
+            `authorized_keys` file.  Includes a comment.
49
+        public_key_data:
50
+            The SSH protocol wire format of the public key.
51
+        private_key:
52
+            A base64 encoded representation of the private key, in
53
+            OpenSSH's v1 private key format.
54
+        private_key_blob:
55
+            The SSH protocol wire format of the private key.
56
+        expected_signature:
57
+            For deterministic signature types, this is the expected
58
+            signature of the vault UUID.  For other types this is
59
+            `None`.
60
+        derived_passphrase:
61
+            For deterministic signature types, this is the "equivalent
62
+            master passphrase" derived from this key (a transformation
63
+            of [`expected_signature`][]).  For other types this is
64
+            `None`.
65
+
66
+    """
67
+
43 68
     public_key: bytes | str
69
+    """"""
44 70
     public_key_data: bytes
71
+    """"""
45 72
     private_key: bytes
73
+    """"""
46 74
     private_key_blob: bytes | None = None
75
+    """"""
47 76
     expected_signature: bytes | None = None
77
+    """"""
48 78
     derived_passphrase: bytes | str | None = None
79
+    """"""
49 80
 
50 81
     def is_suitable(self) -> bool:
82
+        """Return if this key is suitable for use with vault."""
51 83
         return vault.Vault.is_suitable_ssh_key(self.public_key_data)
52 84
 
53 85
 
54 86
 class ValidationSettings(NamedTuple):
87
+    """Validation settings for [`VaultTestConfig`][]s.
88
+
89
+    Attributes:
90
+        allow_unknown_settings:
91
+            See [`_types.validate_vault_config`][].
92
+
93
+    """
94
+
55 95
     allow_unknown_settings: bool
96
+    """"""
56 97
 
57 98
 
58 99
 class VaultTestConfig(NamedTuple):
100
+    """A (not necessarily valid) sample vault config, plus metadata.
101
+
102
+    Attributes:
103
+        config:
104
+            The actual configuration object.  Usually a [`dict`][].
105
+        comment:
106
+            An explanatory comment for what is wrong with this config,
107
+            or empty if the config is valid.  This is intended as
108
+            a debugging message to be shown to the user (e.g. when an
109
+            assertion fails), not as an error message to
110
+            programmatically match against.
111
+        validation_settings:
112
+            See [`_types.validate_vault_config`][].
113
+
114
+    """
115
+
59 116
     config: Any
117
+    """"""
60 118
     comment: str
119
+    """"""
61 120
     validation_settings: ValidationSettings | None
121
+    """"""
62 122
 
63 123
 
64 124
 TEST_CONFIGS: list[VaultTestConfig] = [
... ...
@@ -310,6 +370,7 @@ TEST_CONFIGS: list[VaultTestConfig] = [
310 370
         ValidationSettings(True),
311 371
     ),
312 372
 ]
373
+"""The master list of test configurations for vault."""
313 374
 
314 375
 
315 376
 def is_valid_test_config(conf: VaultTestConfig, /) -> bool:
... ...
@@ -333,6 +394,17 @@ def _test_config_ids(val: VaultTestConfig) -> Any:  # pragma: no cover
333 394
 
334 395
 @strategies.composite
335 396
 def vault_full_service_config(draw: strategies.DrawFn) -> dict[str, int]:
397
+    """Hypothesis strategy for full vault service configurations.
398
+
399
+    Returns a sample configuration with restrictions on length, repeat
400
+    count, and all character classes, while ensuring the settings are
401
+    not obviously unsatisfiable.
402
+
403
+    Args:
404
+        draw:
405
+            The `draw` function, as provided for by hypothesis.
406
+
407
+    """
336 408
     repeat = draw(strategies.integers(min_value=0, max_value=10))
337 409
     lower = draw(strategies.integers(min_value=0, max_value=10))
338 410
     upper = draw(strategies.integers(min_value=0, max_value=10))
... ...
@@ -400,7 +472,7 @@ def smudged_vault_test_config(
400 472
 
401 473
     Args:
402 474
         draw:
403
-            The hypothesis draw function.
475
+            The `draw` function, as provided for by hypothesis.
404 476
         config:
405 477
             A strategy which generates [`VaultTestConfig`][] objects.
406 478
 
... ...
@@ -421,7 +493,7 @@ def smudged_vault_test_config(
421 493
         services.append(obj['global'])
422 494
     assert all(isinstance(x, dict) for x in services), (
423 495
         'is_smudgable_vault_test_config guard failed to '
424
-        'ensure each setings dict is a dict'
496
+        'ensure each settings dict is a dict'
425 497
     )
426 498
     for service in services:
427 499
         for key in ('phrase',):
... ...
@@ -453,20 +525,75 @@ def smudged_vault_test_config(
453 525
 
454 526
 
455 527
 class KnownSSHAgent(str, enum.Enum):
528
+    """Known SSH agents.
529
+
530
+    Attributes:
531
+        UNKNOWN:
532
+            Not a known agent, or not known statically.
533
+        Pageant:
534
+            The agent from Simon Tatham's PuTTY suite.
535
+        OpenSSHAgent:
536
+            The agent from OpenBSD's OpenSSH suite.
537
+
538
+    """
539
+
456 540
     UNKNOWN: str = '(unknown)'
541
+    """"""
457 542
     Pageant: str = 'Pageant'
543
+    """"""
458 544
     OpenSSHAgent: str = 'OpenSSHAgent'
545
+    """"""
459 546
 
460 547
 
461 548
 class SpawnedSSHAgentInfo(NamedTuple):
549
+    """Info about a spawned SSH agent, as provided by some fixtures.
550
+
551
+    Differs from [`RunningSSHAgentInfo`][] in that this info object
552
+    already provides a functional client connected to the agent, but not
553
+    the address.
554
+
555
+    Attributes:
556
+        agent_type:
557
+            The agent's type.
558
+        client:
559
+            An SSH agent client connected to this agent.
560
+        isolated:
561
+            Whether this agent was spawned specifically for this test
562
+            suite, with attempts to isolate it from the user.  If false,
563
+            then the user may be interacting with the agent externally,
564
+            meaning e.g. keys other than the test keys may be visible in
565
+            this agent.
566
+
567
+    """
568
+
462 569
     agent_type: KnownSSHAgent
570
+    """"""
463 571
     client: ssh_agent.SSHAgentClient
572
+    """"""
464 573
     isolated: bool
574
+    """"""
465 575
 
466 576
 
467 577
 class RunningSSHAgentInfo(NamedTuple):
578
+    """Info about a running SSH agent, as provided by some fixtures.
579
+
580
+    Differs from [`SpawnedSSHAgentInfo`][] in that this info object
581
+    provides only an address of the agent, not a functional client
582
+    already connected to it.  The running SSH agent may or may not be
583
+    isolated.
584
+
585
+    Attributes:
586
+        socket:
587
+            A socket address to connect to the agent.
588
+        agent_type:
589
+            The agent's type.
590
+
591
+    """
592
+
468 593
     socket: str
594
+    """"""
469 595
     agent_type: KnownSSHAgent
596
+    """"""
470 597
 
471 598
 
472 599
 ALL_KEYS: Mapping[str, SSHTestKey] = {
... ...
@@ -998,21 +1125,34 @@ Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQ==
998 1125
         derived_passphrase=None,
999 1126
     ),
1000 1127
 }
1128
+"""The master list of SSH test keys."""
1001 1129
 SUPPORTED_KEYS: Mapping[str, SSHTestKey] = {
1002 1130
     k: v for k, v in ALL_KEYS.items() if v.is_suitable()
1003 1131
 }
1132
+"""The subset of SSH test keys suitable for use with vault."""
1004 1133
 UNSUITABLE_KEYS: Mapping[str, SSHTestKey] = {
1005 1134
     k: v for k, v in ALL_KEYS.items() if not v.is_suitable()
1006 1135
 }
1136
+"""The subset of SSH test keys not suitable for use with vault."""
1007 1137
 
1008 1138
 DUMMY_SERVICE = 'service1'
1139
+"""A standard/sample service name."""
1009 1140
 DUMMY_PASSPHRASE = 'my secret passphrase'
1141
+"""A standard/sample passphrase."""
1010 1142
 DUMMY_KEY1 = SUPPORTED_KEYS['ed25519'].public_key_data
1143
+"""A sample universally supported SSH test key (in wire format)."""
1011 1144
 DUMMY_KEY1_B64 = base64.standard_b64encode(DUMMY_KEY1).decode('ASCII')
1145
+"""
1146
+A sample universally supported SSH test key (in `authorized_keys` format).
1147
+"""
1012 1148
 DUMMY_KEY2 = SUPPORTED_KEYS['rsa'].public_key_data
1149
+"""A second supported SSH test key (in wire format)."""
1013 1150
 DUMMY_KEY2_B64 = base64.standard_b64encode(DUMMY_KEY2).decode('ASCII')
1151
+"""A second supported SSH test key (in `authorized_keys` format)."""
1014 1152
 DUMMY_KEY3 = SUPPORTED_KEYS['ed448'].public_key_data
1153
+"""A third supported SSH test key (in wire format)."""
1015 1154
 DUMMY_KEY3_B64 = base64.standard_b64encode(DUMMY_KEY3).decode('ASCII')
1155
+"""A third supported SSH test key (in `authorized_keys` format)."""
1016 1156
 DUMMY_CONFIG_SETTINGS = {
1017 1157
     'length': 10,
1018 1158
     'upper': 1,
... ...
@@ -1023,8 +1163,15 @@ DUMMY_CONFIG_SETTINGS = {
1023 1163
     'dash': 1,
1024 1164
     'symbol': 1,
1025 1165
 }
1166
+"""Sample vault settings."""
1026 1167
 DUMMY_RESULT_PASSPHRASE = b'.2V_QJkd o'
1168
+"""
1169
+The passphrase derived from [`DUMMY_SERVICE`][] using [`DUMMY_PASSPHRASE`][].
1170
+"""
1027 1171
 DUMMY_RESULT_KEY1 = b'E<b<{ -7iG'
1172
+"""
1173
+The passphrase derived from [`DUMMY_SERVICE`][] using [`DUMMY_KEY1`][].
1174
+"""
1028 1175
 DUMMY_PHRASE_FROM_KEY1_RAW = (
1029 1176
     b'\x00\x00\x00\x0bssh-ed25519'
1030 1177
     b'\x00\x00\x00@\xf0\x98\x19\x80l\x1a\x97\xd5&\x03n'
... ...
@@ -1032,10 +1179,23 @@ DUMMY_PHRASE_FROM_KEY1_RAW = (
1032 1179
     b'\x1d\xaf\xfd\r\x08\x1f\xec\xf8s\x9b\x8c_U9\x16|ST,'
1033 1180
     b'\x1eR\xbb0\xed\x7f\x89\xe2/iQU\xd8\x9e\xa6\x02'
1034 1181
 )
1182
+"""
1183
+The "equivalent master passphrase" derived from [`DUMMY_KEY1`][] (raw format).
1184
+"""
1035 1185
 DUMMY_PHRASE_FROM_KEY1 = b'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg=='
1186
+"""
1187
+The "equivalent master passphrase" derived from [`DUMMY_KEY1`][] (in base64).
1188
+"""
1036 1189
 
1037 1190
 VAULT_MASTER_KEY = 'vault key'
1191
+"""
1192
+The storage passphrase used to encrypt all sample vault native configurations.
1193
+"""
1038 1194
 VAULT_V02_CONFIG = 'P7xeh5y4jmjpJ2pFq4KUcTVoaE9ZOEkwWmpVTURSSWQxbGt6emN4aFE4eFM3anVPbDRNTGpOLzY3eDF5aE1YTm5LNWh5Q1BwWTMwM3M5S083MWRWRFlmOXNqSFJNcStGMWFOS3c2emhiOUNNenZYTmNNMnZxaUErdlRoOGF2ZHdGT1ZLNTNLOVJQcU9jWmJrR3g5N09VcVBRZ0ZnSFNUQy9HdFVWWnFteVhRVkY3MHNBdnF2ZWFEbFBseWRGelE1c3BFTnVUckRQdWJSL29wNjFxd2Y2ZVpob3VyVzRod3FKTElTenJ1WTZacTJFOFBtK3BnVzh0QWVxcWtyWFdXOXYyenNQeFNZbWt1MDU2Vm1kVGtISWIxWTBpcWRFbyswUVJudVVhZkVlNVpGWDA4WUQ2Q2JTWW81SnlhQ2Zxa3cxNmZoQjJES0Uyd29rNXpSck5iWVBrVmEwOXFya1NpMi9saU5LL3F0M3N3MjZKekNCem9ER2svWkZ0SUJLdmlHRno0VlQzQ3pqZTBWcTM3YmRiNmJjTkhqUHZoQ0NxMW1ldW1XOFVVK3pQMEtUMkRMVGNvNHFlOG40ck5KcGhsYXg1b1VzZ1NYU1B2T3RXdEkwYzg4NWE3YWUzOWI1MDI0MThhMWZjODQ3MDA2OTJmNDQ0MDkxNGFiNmRlMGQ2YjZiNjI5NGMwN2IwMmI4MGZi'
1195
+"""
1196
+A sample vault native configuration, in v0.2 format, encoded in base64
1197
+and encrypted with [`VAULT_MASTER_KEY`][].
1198
+"""
1039 1199
 VAULT_V02_CONFIG_DATA = {
1040 1200
     'global': {
1041 1201
         'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
... ...
@@ -1047,7 +1207,15 @@ VAULT_V02_CONFIG_DATA = {
1047 1207
         DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1048 1208
     },
1049 1209
 }
1210
+"""
1211
+The plaintext contents (a vault native configuration) stored in
1212
+[`VAULT_V02_CONFIG`][].
1213
+"""
1050 1214
 VAULT_V03_CONFIG = 'sBPBrr8BFHPxSJkV/A53zk9zwDQHFxLe6UIusCVvzFQre103pcj5xxmE11lMTA0U2QTYjkhRXKkH5WegSmYpAnzReuRsYZlWWp6N4kkubf+twZ9C3EeggPm7as2Af4TICHVbX4uXpIHeQJf9y1OtqrO+SRBrgPBzgItoxsIxebxVKgyvh1CZQOSkn7BIzt9xKhDng3ubS4hQ91fB0QCumlldTbUl8tj4Xs5JbvsSlUMxRlVzZ0OgAOrSsoWELXmsp6zXFa9K6wIuZa4wQuMLQFHiA64JO1CR3I+rviWCeMlbTOuJNx6vMB5zotKJqA2hIUpN467TQ9vI4g/QTo40m5LT2EQKbIdTvBQAzcV4lOcpr5Lqt4LHED5mKvm/4YfpuuT3I3XCdWfdG5SB7ciiB4Go+xQdddy3zZMiwm1fEwIB8XjFf2cxoJdccLQ2yxf+9diedBP04EsMHrvxKDhQ7/vHl7xF2MMFTDKl3WFd23vvcjpR1JgNAKYprG/e1p/7'
1215
+"""
1216
+A sample vault native configuration, in v0.3 format, encoded in base64
1217
+and encrypted with [`VAULT_MASTER_KEY`][].
1218
+"""
1051 1219
 VAULT_V03_CONFIG_DATA = {
1052 1220
     'global': {
1053 1221
         'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
... ...
@@ -1059,6 +1227,10 @@ VAULT_V03_CONFIG_DATA = {
1059 1227
         DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1060 1228
     },
1061 1229
 }
1230
+"""
1231
+The plaintext contents (a vault native configuration) stored in
1232
+[`VAULT_V03_CONFIG`][].
1233
+"""
1062 1234
 VAULT_STOREROOM_CONFIG_ZIPPED = b"""
1063 1235
 UEsDBBQAAAAIAJ1WGVnTVFGT0gAAAOYAAAAFAAAALmtleXMFwclSgzAAANC7n9GrBzBldcYDE5Al
1064 1236
 EKbFAvGWklBAtqYsBcd/973fw8LFox76w/vb34tzhD5OATeEAk6tJ6Fbp3WrvkJO7l0KIjtxCLfY
... ...
@@ -1111,6 +1283,11 @@ AAAACACdVhlZ3Wlf4QUDAADUAwAAAgAAAAAAAAABAAAApIH1AAAAMDBQSwECHgMUAAAACACdVhlZ
1111 1283
 AgAAAgAAAAAAAAABAAAApIEEBgAAMDlQSwECHgMUAAAACACdVhlZyjtiYvgBAABrAgAAAgAAAAAA
1112 1284
 AAABAAAApIHsBwAAMWFQSwUGAAAAAAUABQDzAAAABAoAAAAA
1113 1285
 """
1286
+"""
1287
+A sample vault native configuration, in storeroom format, encrypted with
1288
+[`VAULT_MASTER_KEY`][].  The configuration is compressed (zip archive)
1289
+and then encoded in base64.
1290
+"""
1114 1291
 VAULT_STOREROOM_CONFIG_DATA = {
1115 1292
     'global': {
1116 1293
         'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
... ...
@@ -1122,6 +1299,10 @@ VAULT_STOREROOM_CONFIG_DATA = {
1122 1299
         DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1123 1300
     },
1124 1301
 }
1302
+"""
1303
+The parsed vault configuration stored in
1304
+[`VAULT_STOREROOM_CONFIG_ZIPPED`][].
1305
+"""
1125 1306
 
1126 1307
 _VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED_JAVASCRIPT_SOURCE = """
1127 1308
 // Executed in the top-level directory of the vault project code, in Node.js.
... ...
@@ -1131,6 +1312,10 @@ let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
1131 1312
 await store._storeroom.put('/services/array/', ['entry1','entry2'])
1132 1313
 // The resulting "broken-dir" was then zipped manually.
1133 1314
 """
1315
+"""
1316
+The JavaScript source for the script that generated the storeroom
1317
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED`][].
1318
+"""
1134 1319
 VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED = b"""
1135 1320
 UEsDBBQAAgAIAHijH1kjc0ql0gAAAOYAAAAFAAAALmtleXMFwclygjAAANB7P8Mrh7LIYmd6oGxC
1136 1321
 HKwTJJgbNpBKCpGAhNTpv/e952ZpxHTjw+bN+HuJJABEikvHecD0pLgpgYKWjue0CZGk19mKF+4f
... ...
@@ -1167,6 +1352,17 @@ ox9ZI3NKpdIAAADmAAAABQAAAAAAAAABAAAApIEAAAAALmtleXNQSwECHgMUAAIACAB4ox9Zfgvu
1167 1352
 AgAAAAAAAAABAAAApIHtAgAAMWFQSwECHgMUAAIACAB4ox9ZGgj3mrkBAAAXAgAAAgAAAAAAAAAB
1168 1353
 AAAApIHIBAAAMWVQSwUGAAAAAAQABADDAAAAoQYAAAAA
1169 1354
 """
1355
+"""
1356
+A sample corrupted storeroom archive, encrypted with
1357
+[`VAULT_MASTER_KEY`][].  The configuration is compressed (zip archive)
1358
+and then encoded in base64.
1359
+
1360
+The archive contains a directory `/services/array/` that claims to have
1361
+two child items 'entry1' and 'entry2', but no such child items are
1362
+present in the archive.  See
1363
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED_JAVASCRIPT_SOURCE`][] for
1364
+the exact script that created this archive.
1365
+"""
1170 1366
 
1171 1367
 _VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2_JAVASCRIPT_SOURCE = """
1172 1368
 // Executed in the top-level directory of the vault project code, in Node.js.
... ...
@@ -1176,6 +1372,10 @@ let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
1176 1372
 await store._storeroom.put('/services/array/', 'not a directory index')
1177 1373
 // The resulting "broken-dir" was then zipped manually.
1178 1374
 """
1375
+"""
1376
+The JavaScript source for the script that generated the storeroom
1377
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2`][].
1378
+"""
1179 1379
 VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2 = b"""
1180 1380
 UEsDBAoAAAAAAM6NSVmrcHdV5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV3ZS9LZkJp
1181 1381
 L0V0OUcrZmxYM3gxaFU4ZjE4YlE3S253bHoxN0IxSDE3cUhVOGdWK2RpWWY5MTdFZ0YrSStidEpZ
... ...
@@ -1217,6 +1417,16 @@ LmtleXNQSwECHgMKAAAAAADOjUlZJg3/BhcCAAAXAgAAAgAAAAAAAAAAAAAApIEJAQAAMGJQSwEC
1217 1417
 HgMKAAAAAADOjUlZTNfdphcCAAAXAgAAAgAAAAAAAAAAAAAApIFAAwAAMGZQSwECHgMKAAAAAADO
1218 1418
 jUlZn9rNID8CAAA/AgAAAgAAAAAAAAAAAAAApIF3BQAAMWRQSwUGAAAAAAQABADDAAAA1gcAAAAA
1219 1419
 """
1420
+"""
1421
+A sample corrupted storeroom archive, encrypted with
1422
+[`VAULT_MASTER_KEY`][].  The configuration is compressed (zip archive)
1423
+and then encoded in base64.
1424
+
1425
+The archive contains a directory `/services/array/` whose list of child
1426
+items does not adhere to the serialization format.  See
1427
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2_JAVASCRIPT_SOURCE`][] for
1428
+the exact script that created this archive.
1429
+"""
1220 1430
 
1221 1431
 _VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3_JAVASCRIPT_SOURCE = """
1222 1432
 // Executed in the top-level directory of the vault project code, in Node.js.
... ...
@@ -1226,6 +1436,10 @@ let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
1226 1436
 await store._storeroom.put('/services/array/', [null, 1, true, [], {}])
1227 1437
 // The resulting "broken-dir" was then zipped manually.
1228 1438
 """
1439
+"""
1440
+The JavaScript source for the script that generated the storeroom
1441
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3`][].
1442
+"""
1229 1443
 VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3 = b"""
1230 1444
 UEsDBAoAAAAAAEOPSVnVlcff5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV4dVBHUDBi
1231 1445
 YkxrUVdvWnV5ZUJQRy8xdmM2MCt6MThOa3BsS09ydFAvUTVnQmxkYVpIOG10dTE5VWZFNGdGRGRj
... ...
@@ -1267,6 +1481,16 @@ LmtleXNQSwECHgMKAAAAAABDj0lZ77OVHxcCAAAXAgAAAgAAAAAAAAAAAAAApIEJAQAAMGNQSwEC
1267 1481
 HgMKAAAAAABDj0lZGk9LVj8CAAA/AgAAAgAAAAAAAAAAAAAApIFAAwAAMTRQSwECHgMKAAAAAABD
1268 1482
 j0lZUkzxBhcCAAAXAgAAAgAAAAAAAAAAAAAApIGfBQAAMTZQSwUGAAAAAAQABADDAAAA1gcAAAAA
1269 1483
 """
1484
+"""
1485
+A sample corrupted storeroom archive, encrypted with
1486
+[`VAULT_MASTER_KEY`][].  The configuration is compressed (zip archive)
1487
+and then encoded in base64.
1488
+
1489
+The archive contains a directory `/services/array/` whose list of child
1490
+items are not all valid item names.  See
1491
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3_JAVASCRIPT_SOURCE`][] for
1492
+the exact script that created this archive.
1493
+"""
1270 1494
 
1271 1495
 _VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4_JAVASCRIPT_SOURCE = """
1272 1496
 // Executed in the top-level directory of the vault project code, in Node.js.
... ...
@@ -1277,6 +1501,10 @@ await store._storeroom.put('/dir/subdir/', [])
1277 1501
 await store._storeroom.put('/dir/', [])
1278 1502
 // The resulting "broken-dir" was then zipped manually.
1279 1503
 """
1504
+"""
1505
+The JavaScript source for the script that generated the storeroom
1506
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4`][].
1507
+"""
1280 1508
 VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4 = b"""
1281 1509
 UEsDBAoAAAAAAE+5SVloORS+5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV6dWRoNkRQ
1282 1510
 YTlNSWFabHZ5TytVYTFuamhjV2hIaTFBU0lKYW5zcXBxVlA0blN2V0twUzdZOUc2bjFSbi8vUnVM
... ...
@@ -1318,24 +1546,73 @@ Weut7lIXAgAAFwIAAAIAAAAAAAAAAAAAAKSBCQEAADAzUEsBAh4DCgAAAAAAT7lJWUV5MuArAgAA
1318 1546
 KwIAAAIAAAAAAAAAAAAAAKSBQAMAADEwUEsBAh4DCgAAAAAAT7lJWQ98rH0XAgAAFwIAAAIAAAAA
1319 1547
 AAAAAAAAAKSBiwUAADFkUEsFBgAAAAAEAAQAwwAAAMIHAAAAAA==
1320 1548
 """
1549
+"""
1550
+A sample corrupted storeroom archive, encrypted with
1551
+[`VAULT_MASTER_KEY`][].  The configuration is compressed (zip archive)
1552
+and then encoded in base64.
1553
+
1554
+The archive contains two directories `/dir/` and `/dir/subdir/`, where
1555
+`/dir/subdir/` is a correctly serialized directory, but `/dir/` does not
1556
+contain `/dir/subdir/` in its list of child items.  See
1557
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4_JAVASCRIPT_SOURCE`][] for
1558
+the exact script that created this archive.
1559
+"""
1321 1560
 
1322 1561
 CANNOT_LOAD_CRYPTOGRAPHY = (
1323 1562
     "Cannot load the required Python module 'cryptography'."
1324 1563
 )
1564
+"""
1565
+The expected `derivepassphrase` error message when the `cryptography`
1566
+module cannot be loaded, which is needed e.g. by the `export vault`
1567
+subcommands.
1568
+"""
1325 1569
 
1326 1570
 skip_if_cryptography_support = pytest.mark.skipif(
1327 1571
     importlib.util.find_spec('cryptography') is not None,
1328 1572
     reason='cryptography support available; cannot test "no support" scenario',
1329 1573
 )
1574
+"""
1575
+A cached pytest mark to skip this test if cryptography support is
1576
+available.  Usually this means that the test targets
1577
+`derivepassphrase`'s fallback functionality, which is not available
1578
+whenever the primary functionality is.
1579
+"""
1330 1580
 skip_if_no_cryptography_support = pytest.mark.skipif(
1331 1581
     importlib.util.find_spec('cryptography') is None,
1332 1582
     reason='no "cryptography" support',
1333 1583
 )
1584
+"""
1585
+A cached pytest mark to skip this test if cryptography support is not
1586
+available.  Usually this means that the test targets the
1587
+`derivepassphrase export vault` subcommand, whose functionality depends
1588
+on cryptography support being available.
1589
+"""
1334 1590
 
1335 1591
 
1336 1592
 def hypothesis_settings_coverage_compatible(
1337 1593
     f: Any = None,
1338 1594
 ) -> Any:
1595
+    """Return (or decorate `f` with) coverage-friendly hypothesis settings.
1596
+
1597
+    Specifically, we increase the deadline 40-fold if we detect we are
1598
+    running under coverage testing, because the slow Python trace
1599
+    function (necessary on PyPy) drastically increases runtime for
1600
+    hypothesis tests.
1601
+
1602
+    In any case, we *also* reduce the state machine step count to 32
1603
+    steps per run, because the current state machines defined in the
1604
+    tests rather benefit from broad testing rather than deep testing.
1605
+
1606
+    Args:
1607
+        f:
1608
+            An optional object to decorate with these settings.
1609
+
1610
+    Returns:
1611
+        The modified hypothesis settings, as a settings object.  If
1612
+        decorating a function/class, return that function/class
1613
+        directly, after decorating.
1614
+
1615
+    """
1339 1616
     settings = (
1340 1617
         hypothesis.settings(
1341 1618
             # Running under coverage with the Python tracer increases
... ...
@@ -1362,6 +1639,22 @@ def hypothesis_settings_coverage_compatible(
1362 1639
 def hypothesis_settings_coverage_compatible_with_caplog(
1363 1640
     f: Any = None,
1364 1641
 ) -> Any:
1642
+    """Return (or decorate `f` with) coverage-friendly hypothesis settings.
1643
+
1644
+    This variant of [`hypothesis_settings_coverage_compatible`][] does
1645
+    all the same, and additionally disables the check for function
1646
+    scoped pytest fixtures such as `caplog`.
1647
+
1648
+    Args:
1649
+        f:
1650
+            An optional object to decorate with these settings.
1651
+
1652
+    Returns:
1653
+        The modified hypothesis settings, as a settings object.  If
1654
+        decorating a function/class, return that function/class
1655
+        directly, after decorating.
1656
+
1657
+    """
1365 1658
     parent_settings = hypothesis_settings_coverage_compatible()
1366 1659
     settings = hypothesis.settings(
1367 1660
         parent=parent_settings,
... ...
@@ -1374,6 +1667,12 @@ def hypothesis_settings_coverage_compatible_with_caplog(
1374 1667
 
1375 1668
 
1376 1669
 def list_keys(self: Any = None) -> list[_types.SSHKeyCommentPair]:
1670
+    """Return a list of all SSH test keys, as key/comment pairs.
1671
+
1672
+    Intended as a monkeypatching replacement for
1673
+    [`ssh_agent.SSHAgentClient.list_keys`][].
1674
+
1675
+    """
1377 1676
     del self  # Unused.
1378 1677
     Pair = _types.SSHKeyCommentPair  # noqa: N806
1379 1678
     return [
... ...
@@ -1385,6 +1684,15 @@ def list_keys(self: Any = None) -> list[_types.SSHKeyCommentPair]:
1385 1684
 def sign(
1386 1685
     self: Any, key: bytes | bytearray, message: bytes | bytearray
1387 1686
 ) -> bytes:
1687
+    """Return the signature of `message` under `key`.
1688
+
1689
+    Can only handle keys in [`SUPPORTED_KEYS`][], and only the vault
1690
+    UUID as the message.
1691
+
1692
+    Intended as a monkeypatching replacement for
1693
+    [`ssh_agent.SSHAgentClient.sign`][].
1694
+
1695
+    """
1388 1696
     del self  # Unused.
1389 1697
     assert message == vault.Vault._UUID
1390 1698
     for value in SUPPORTED_KEYS.values():
... ...
@@ -1395,6 +1703,14 @@ def sign(
1395 1703
 
1396 1704
 
1397 1705
 def list_keys_singleton(self: Any = None) -> list[_types.SSHKeyCommentPair]:
1706
+    """Return a singleton list of the first supported SSH test key.
1707
+
1708
+    The key is returned as a key/comment pair.
1709
+
1710
+    Intended as a monkeypatching replacement for
1711
+    [`ssh_agent.SSHAgentClient.list_keys`][].
1712
+
1713
+    """
1398 1714
     del self  # Unused.
1399 1715
     Pair = _types.SSHKeyCommentPair  # noqa: N806
1400 1716
     list1 = [
... ...
@@ -1405,6 +1721,14 @@ def list_keys_singleton(self: Any = None) -> list[_types.SSHKeyCommentPair]:
1405 1721
 
1406 1722
 
1407 1723
 def suitable_ssh_keys(conn: Any) -> Iterator[_types.SSHKeyCommentPair]:
1724
+    """Return a two-item list of SSH test keys (key/comment pairs).
1725
+
1726
+    Intended as a monkeypatching replacement for
1727
+    `cli._get_suitable_ssh_keys` to better script and test the
1728
+    interactive key selection.  When used this way, `derivepassphrase`
1729
+    believes that only those two keys are loaded and suitable.
1730
+
1731
+    """
1408 1732
     del conn  # Unused.
1409 1733
     Pair = _types.SSHKeyCommentPair  # noqa: N806
1410 1734
     yield from [
... ...
@@ -1419,6 +1743,15 @@ def phrase_from_key(
1419 1743
     *,
1420 1744
     conn: ssh_agent.SSHAgentClient | socket.socket | None = None,
1421 1745
 ) -> bytes:
1746
+    """Return the "equivalent master passphrase" for key.
1747
+
1748
+    Only works for key [`DUMMY_KEY1`][].
1749
+
1750
+    Intended as a monkeypatching replacement for
1751
+    [`vault.Vault.phrase_from_key`][], bypassing communication with an
1752
+    actual SSH agent.
1753
+
1754
+    """
1422 1755
     del conn
1423 1756
     if key == DUMMY_KEY1:  # pragma: no branch
1424 1757
         return DUMMY_PHRASE_FROM_KEY1
... ...
@@ -1431,6 +1764,28 @@ def isolated_config(
1431 1764
     runner: click.testing.CliRunner,
1432 1765
     main_config_str: str | None = None,
1433 1766
 ) -> Iterator[None]:
1767
+    """Provide an isolated configuration setup, as a context.
1768
+
1769
+    This context manager sets up (and changes into) a temporary
1770
+    directory, which holds the user configuration specified in
1771
+    `main_config_str`, if any.  The manager also ensures that the
1772
+    environment variables `HOME` and `USERPROFILE` are set, and that
1773
+    `DERIVEPASSPHRASE_PATH` is unset.  Upon exiting the context, the
1774
+    changes are undone and the temporary directory is removed.
1775
+
1776
+    Args:
1777
+        monkeypatch:
1778
+            A monkeypatch fixture object.
1779
+        runner:
1780
+            A `click` CLI runner harness.
1781
+        main_config_str:
1782
+            Optional TOML file contents, to be used as the user
1783
+            configuration.
1784
+
1785
+    Returns:
1786
+        A context manager, without a return value.
1787
+
1788
+    """
1434 1789
     prog_name = cli.PROG_NAME
1435 1790
     env_name = prog_name.replace(' ', '_').upper() + '_PATH'
1436 1791
     # TODO(the-13th-letter): Rewrite using parenthesized with-statements.
... ...
@@ -1461,6 +1816,28 @@ def isolated_vault_config(
1461 1816
     vault_config: Any,
1462 1817
     main_config_str: str | None = None,
1463 1818
 ) -> Iterator[None]:
1819
+    """Provide an isolated vault configuration setup, as a context.
1820
+
1821
+    Uses [`isolated_config`][] internally.  Beyond those actions, this
1822
+    manager also loads the specified vault configuration into the
1823
+    context.
1824
+
1825
+    Args:
1826
+        monkeypatch:
1827
+            A monkeypatch fixture object.
1828
+        runner:
1829
+            A `click` CLI runner harness.
1830
+        vault_config:
1831
+            A valid vault configuration, to be integrated into the
1832
+            context.
1833
+        main_config_str:
1834
+            Optional TOML file contents, to be used as the user
1835
+            configuration.
1836
+
1837
+    Returns:
1838
+        A context manager, without a return value.
1839
+
1840
+    """
1464 1841
     with isolated_config(
1465 1842
         monkeypatch=monkeypatch, runner=runner, main_config_str=main_config_str
1466 1843
     ):
... ...
@@ -1477,6 +1854,35 @@ def isolated_vault_exporter_config(
1477 1854
     vault_config: str | bytes | None = None,
1478 1855
     vault_key: str | None = None,
1479 1856
 ) -> Iterator[None]:
1857
+    """Provide an isolated vault configuration setup, as a context.
1858
+
1859
+    Works similarly to [`isolated_config`][], except that no user
1860
+    configuration is accepted or integrated into the context.  This
1861
+    manager also accepts a serialized vault-native configuration and
1862
+    a vault encryption key to integrate into the context.
1863
+
1864
+    Args:
1865
+        monkeypatch:
1866
+            A monkeypatch fixture object.
1867
+        runner:
1868
+            A `click` CLI runner harness.
1869
+        vault_config:
1870
+            An optional serialized vault-native configuration, to be
1871
+            integrated into the context.  If a text string, then the
1872
+            contents are written to the file `.vault`.  If a byte
1873
+            string, then it is treated as base64-encoded zip file
1874
+            contents, which---once inside the `.vault` directory---will
1875
+            be extracted into the current directory.
1876
+        vault_key:
1877
+            An optional encryption key presumably for the stored
1878
+            vault-native configuration.  If given, then the environment
1879
+            variable `VAULT_KEY` will be populated with this key while
1880
+            the context is active.
1881
+
1882
+    Returns:
1883
+        A context manager, without a return value.
1884
+
1885
+    """
1480 1886
     # TODO(the-13th-letter): Remove the fallback implementation.
1481 1887
     # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.10
1482 1888
     if TYPE_CHECKING:
... ...
@@ -1535,6 +1941,13 @@ def isolated_vault_exporter_config(
1535 1941
 
1536 1942
 
1537 1943
 def auto_prompt(*args: Any, **kwargs: Any) -> str:
1944
+    """Return [`DUMMY_PASSPHRASE`][].
1945
+
1946
+    Intended as a monkeypatching replacement for
1947
+    `cli.prompt_for_passphrase` to better script and test the
1948
+    interactive passphrase queries.
1949
+
1950
+    """
1538 1951
     del args, kwargs  # Unused.
1539 1952
     return DUMMY_PASSPHRASE
1540 1953
 
... ...
@@ -1601,6 +2014,7 @@ class ReadableResult(NamedTuple):
1601 2014
 
1602 2015
     @classmethod
1603 2016
     def parse(cls, r: click.testing.Result, /) -> Self:
2017
+        """Return a readable result object, given a result."""
1604 2018
         try:
1605 2019
             stderr = r.stderr
1606 2020
         except ValueError:
... ...
@@ -1672,6 +2086,29 @@ class ReadableResult(NamedTuple):
1672 2086
 
1673 2087
 
1674 2088
 def parse_sh_export_line(line: str, *, env_name: str) -> str:
2089
+    """Parse the output of typical SSH agents' SSH_AUTH_SOCK lines.
2090
+
2091
+    Intentionally parses only a small subset of sh(1) syntax which works
2092
+    with current OpenSSH and PuTTY output.  We require exactly one
2093
+    variable setting, and one export instruction, both on the same line,
2094
+    and perhaps combined into one statement.  Terminating semicolons
2095
+    after each command are ignored.
2096
+
2097
+    Args:
2098
+        line:
2099
+            A line of sh(1) script to parse.
2100
+        env_name:
2101
+            The name of the environment variable to expect.
2102
+
2103
+    Returns:
2104
+        The parsed environment variable value.
2105
+
2106
+    Raises:
2107
+        ValueError:
2108
+            Cannot parse the sh script.  Perhaps it is too complex,
2109
+            perhaps it is malformed.
2110
+
2111
+    """
1675 2112
     line = line.rstrip('\r\n')
1676 2113
     shlex_parser = shlex.shlex(
1677 2114
         instream=line, posix=True, punctuation_chars=True
... ...
@@ -70,7 +70,28 @@ class SpawnFunc(Protocol):
70 70
         self,
71 71
         executable: str | None,
72 72
         env: dict[str, str],
73
-    ) -> subprocess.Popen[str] | None: ...
73
+    ) -> subprocess.Popen[str] | None:
74
+        """Spawn the SSH agent.
75
+
76
+        Args:
77
+            executable:
78
+                The respective SSH agent executable.
79
+            env:
80
+                The new environment for the respective agent.  Should
81
+                typically not include an SSH_AUTH_SOCK variable.
82
+
83
+        Returns:
84
+            The spawned SSH agent subprocess.  If the executable is
85
+            `None`, then return `None` directly.
86
+
87
+            It is the caller's responsibility to clean up the spawned
88
+            subprocess.
89
+
90
+        Raises:
91
+            OSError:
92
+                The [`subprocess.Popen`][] call failed.  See there.
93
+
94
+        """
74 95
 
75 96
 
76 97
 def _spawn_pageant(  # pragma: no cover
... ...
@@ -190,12 +211,27 @@ _spawn_handlers = [
190 211
     ('ssh-agent', _spawn_openssh_agent, tests.KnownSSHAgent.OpenSSHAgent),
191 212
     ('(system)', _spawn_noop, tests.KnownSSHAgent.UNKNOWN),
192 213
 ]
214
+"""
215
+The standard registry of agent spawning functions.
216
+"""
193 217
 
194 218
 Popen = TypeVar('Popen', bound=subprocess.Popen)
195 219
 
196 220
 
197 221
 @contextlib.contextmanager
198 222
 def _terminate_on_exit(proc: Popen) -> Iterator[Popen]:
223
+    """Terminate and wait for the subprocess upon exiting the context.
224
+
225
+    Args:
226
+        proc:
227
+            The subprocess to manage.
228
+
229
+    Returns:
230
+        A context manager.  Upon entering the manager, return the
231
+        managed subprocess.  Upon exiting the manager, terminate the
232
+        process and wait for it.
233
+
234
+    """
199 235
     try:
200 236
         yield proc
201 237
     finally:
... ...
@@ -204,7 +240,7 @@ def _terminate_on_exit(proc: Popen) -> Iterator[Popen]:
204 240
 
205 241
 
206 242
 class CannotSpawnError(RuntimeError):
207
-    pass
243
+    """Cannot spawn the SSH agent."""
208 244
 
209 245
 
210 246
 def _spawn_named_agent(
... ...
@@ -212,6 +248,41 @@ def _spawn_named_agent(
212 248
     spawn_func: SpawnFunc,
213 249
     agent_type: tests.KnownSSHAgent,
214 250
 ) -> Iterator[tests.SpawnedSSHAgentInfo]:  # pragma: no cover
251
+    """Spawn the named SSH agent and check that it is operational.
252
+
253
+    Using the correct agent-specific spawn function from the
254
+    [`spawn_handlers`][] registry, spawn the named SSH agent (according
255
+    to its declared type), then set up the communication channel and
256
+    yield an SSH agent client connected to this agent.  After resuming,
257
+    tear down the communication channel and terminate the SSH agent.
258
+
259
+    The SSH agent's instructions for setting up the communication
260
+    channel are parsed with [`tests.parse_sh_export_line`][].  See the
261
+    caveats there.
262
+
263
+    Args:
264
+        exec_name:
265
+            The executable to spawn.
266
+        spawn_func:
267
+            The agent-specific spawn function.
268
+        agent_type:
269
+            The agent type.
270
+
271
+    Yields:
272
+        A 3-tuple containing the agent type, an SSH agent client
273
+        connected to this agent, and a boolean indicating whether this
274
+        agent was actually spawned in an isolated manner.
275
+
276
+        Only one tuple will ever be yielded.  After resuming, the
277
+        connected client will be torn down, as will the agent if it was
278
+        isolated.
279
+
280
+    Raises:
281
+        CannotSpawnError:
282
+            We failed to spawn the agent or otherwise set up the
283
+            environment/communication channel/etc.
284
+
285
+    """
215 286
     # pytest's fixture system does not seem to guarantee that
216 287
     # environment variables are set up correctly if nested and
217 288
     # parametrized fixtures are used: it is possible that "outer"
... ...
@@ -242,6 +242,15 @@ def vault_config_exporter_shell_interpreter(  # noqa: C901
242 242
     command: click.BaseCommand | None = None,
243 243
     runner: click.testing.CliRunner | None = None,
244 244
 ) -> Iterator[click.testing.Result]:
245
+    """A rudimentary sh(1) interpreter for `--export-as=sh` output.
246
+
247
+    Assumes a script as emitted by `derivepassphrase vault
248
+    --export-as=sh --export -` and interprets the calls to
249
+    `derivepassphrase vault` within.  (One call per line, skips all
250
+    other lines.)  Also has rudimentary support for (quoted)
251
+    here-documents using `HERE` as the marker.
252
+
253
+    """
245 254
     if isinstance(script, str):  # pragma: no cover
246 255
         script = script.splitlines(False)
247 256
     if prog_name_list is None:  # pragma: no cover
... ...
@@ -285,6 +294,8 @@ def vault_config_exporter_shell_interpreter(  # noqa: C901
285 294
 
286 295
 
287 296
 class TestAllCLI:
297
+    """Tests uniformly for all command-line interfaces."""
298
+
288 299
     @pytest.mark.parametrize(
289 300
         ['command', 'non_eager_arguments'],
290 301
         [
... ...
@@ -342,6 +353,7 @@ class TestAllCLI:
342 353
         arguments: list[str],
343 354
         non_eager_arguments: list[str],
344 355
     ) -> None:
356
+        """Eager options terminate option and argument processing."""
345 357
         runner = click.testing.CliRunner(mix_stderr=False)
346 358
         with tests.isolated_config(
347 359
             monkeypatch=monkeypatch,
... ...
@@ -389,6 +401,7 @@ class TestAllCLI:
389 401
         command_line: list[str],
390 402
         input: str | None,
391 403
     ) -> None:
404
+        """Respect the `NO_COLOR` and `FORCE_COLOR` environment variables."""
392 405
         # Force color on if force_color.  Otherwise force color off if
393 406
         # no_color.  Otherwise set color if and only if we have a TTY.
394 407
         color = force_color or not no_color if isatty else force_color
... ...
@@ -420,10 +433,13 @@ class TestAllCLI:
420 433
 
421 434
 
422 435
 class TestCLI:
436
+    """Tests for the `derivepassphrase vault` command-line interface."""
437
+
423 438
     def test_200_help_output(
424 439
         self,
425 440
         monkeypatch: pytest.MonkeyPatch,
426 441
     ) -> None:
442
+        """The `--help` option emits help text."""
427 443
         runner = click.testing.CliRunner(mix_stderr=False)
428 444
         with tests.isolated_config(
429 445
             monkeypatch=monkeypatch,
... ...
@@ -446,6 +462,7 @@ class TestCLI:
446 462
         self,
447 463
         monkeypatch: pytest.MonkeyPatch,
448 464
     ) -> None:
465
+        """The `--version` option emits version information."""
449 466
         runner = click.testing.CliRunner(mix_stderr=False)
450 467
         with tests.isolated_config(
451 468
             monkeypatch=monkeypatch,
... ...
@@ -470,6 +487,7 @@ class TestCLI:
470 487
     def test_201_disable_character_set(
471 488
         self, monkeypatch: pytest.MonkeyPatch, charset_name: str
472 489
     ) -> None:
490
+        """Named character classes can be disabled on the command-line."""
473 491
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
474 492
         option = f'--{charset_name}'
475 493
         charset = vault.Vault._CHARSETS[charset_name].decode('ascii')
... ...
@@ -494,6 +512,7 @@ class TestCLI:
494 512
     def test_202_disable_repetition(
495 513
         self, monkeypatch: pytest.MonkeyPatch
496 514
     ) -> None:
515
+        """Character repetition can be disabled on the command-line."""
497 516
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
498 517
         runner = click.testing.CliRunner(mix_stderr=False)
499 518
         with tests.isolated_config(
... ...
@@ -546,6 +565,7 @@ class TestCLI:
546 565
         monkeypatch: pytest.MonkeyPatch,
547 566
         config: _types.VaultConfig,
548 567
     ) -> None:
568
+        """A stored configured SSH key will be used."""
549 569
         runner = click.testing.CliRunner(mix_stderr=False)
550 570
         with tests.isolated_vault_config(
551 571
             monkeypatch=monkeypatch, runner=runner, vault_config=config
... ...
@@ -573,6 +593,7 @@ class TestCLI:
573 593
     def test_204b_key_from_command_line(
574 594
         self, monkeypatch: pytest.MonkeyPatch
575 595
     ) -> None:
596
+        """An SSH key requested on the command-line will be used."""
576 597
         runner = click.testing.CliRunner(mix_stderr=False)
577 598
         with tests.isolated_vault_config(
578 599
             monkeypatch=monkeypatch,
... ...
@@ -633,6 +654,7 @@ class TestCLI:
633 654
         config: dict[str, Any],
634 655
         key_index: int,
635 656
     ) -> None:
657
+        """A command-line SSH key will override the configured key."""
636 658
         with monkeypatch.context():
637 659
             monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
638 660
             monkeypatch.setattr(
... ...
@@ -661,6 +683,7 @@ class TestCLI:
661 683
         monkeypatch: pytest.MonkeyPatch,
662 684
         running_ssh_agent: tests.RunningSSHAgentInfo,
663 685
     ) -> None:
686
+        """A command-line passphrase will override the configured key."""
664 687
         with monkeypatch.context():
665 688
             monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
666 689
             monkeypatch.setattr(
... ...
@@ -738,6 +761,7 @@ class TestCLI:
738 761
         config: _types.VaultConfig,
739 762
         command_line: list[str],
740 763
     ) -> None:
764
+        """Configuring a passphrase atop an SSH key works, but warns."""
741 765
         with monkeypatch.context():
742 766
             monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
743 767
             monkeypatch.setattr(
... ...
@@ -790,6 +814,7 @@ class TestCLI:
790 814
     def test_210_invalid_argument_range(
791 815
         self, monkeypatch: pytest.MonkeyPatch, option: str
792 816
     ) -> None:
817
+        """Requesting invalidly many characters from a class fails."""
793 818
         runner = click.testing.CliRunner(mix_stderr=False)
794 819
         with tests.isolated_config(
795 820
             monkeypatch=monkeypatch,
... ...
@@ -829,6 +854,7 @@ class TestCLI:
829 854
         input: str | None,
830 855
         check_success: bool,
831 856
     ) -> None:
857
+        """We require or forbid a service argument, depending on options."""
832 858
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
833 859
         runner = click.testing.CliRunner(mix_stderr=False)
834 860
         with tests.isolated_vault_config(
... ...
@@ -879,6 +905,12 @@ class TestCLI:
879 905
         monkeypatch: pytest.MonkeyPatch,
880 906
         caplog: pytest.LogCaptureFixture,
881 907
     ) -> None:
908
+        """Using an empty service name (where permissible) warns.
909
+
910
+        Only the `--config` option can optionally take a service name.
911
+
912
+        """
913
+
882 914
         def is_expected_warning(record: tuple[str, int, str]) -> bool:
883 915
             return is_harmless_config_import_warning(
884 916
                 record
... ...
@@ -940,6 +972,7 @@ class TestCLI:
940 972
         options: list[str],
941 973
         service: bool | None,
942 974
     ) -> None:
975
+        """Incompatible options are detected."""
943 976
         runner = click.testing.CliRunner(mix_stderr=False)
944 977
         with tests.isolated_config(
945 978
             monkeypatch=monkeypatch,
... ...
@@ -970,6 +1003,7 @@ class TestCLI:
970 1003
         caplog: pytest.LogCaptureFixture,
971 1004
         config: Any,
972 1005
     ) -> None:
1006
+        """Importing a configuration works."""
973 1007
         runner = click.testing.CliRunner(mix_stderr=False)
974 1008
         with tests.isolated_vault_config(
975 1009
             monkeypatch=monkeypatch,
... ...
@@ -1006,6 +1040,11 @@ class TestCLI:
1006 1040
         caplog: pytest.LogCaptureFixture,
1007 1041
         conf: tests.VaultTestConfig,
1008 1042
     ) -> None:
1043
+        """Importing a smudged configuration works.
1044
+
1045
+        Tested via hypothesis.
1046
+
1047
+        """
1009 1048
         config = conf.config
1010 1049
         config2 = copy.deepcopy(config)
1011 1050
         _types.clean_up_falsy_vault_config_values(config2)
... ...
@@ -1036,6 +1075,7 @@ class TestCLI:
1036 1075
         self,
1037 1076
         monkeypatch: pytest.MonkeyPatch,
1038 1077
     ) -> None:
1078
+        """Importing an invalid config fails."""
1039 1079
         runner = click.testing.CliRunner(mix_stderr=False)
1040 1080
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1041 1081
             result_ = runner.invoke(
... ...
@@ -1053,6 +1093,7 @@ class TestCLI:
1053 1093
         self,
1054 1094
         monkeypatch: pytest.MonkeyPatch,
1055 1095
     ) -> None:
1096
+        """Importing an invalid config fails."""
1056 1097
         runner = click.testing.CliRunner(mix_stderr=False)
1057 1098
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1058 1099
             result_ = runner.invoke(
... ...
@@ -1070,6 +1111,7 @@ class TestCLI:
1070 1111
         self,
1071 1112
         monkeypatch: pytest.MonkeyPatch,
1072 1113
     ) -> None:
1114
+        """Importing an invalid config fails."""
1073 1115
         runner = click.testing.CliRunner(mix_stderr=False)
1074 1116
         # `isolated_vault_config` validates the configuration.  So, to
1075 1117
         # pass an actual broken configuration, we must open the
... ...
@@ -1102,6 +1144,7 @@ class TestCLI:
1102 1144
         monkeypatch: pytest.MonkeyPatch,
1103 1145
         export_options: list[str],
1104 1146
     ) -> None:
1147
+        """Exporting the default, empty config works."""
1105 1148
         runner = click.testing.CliRunner(mix_stderr=False)
1106 1149
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1107 1150
             cli._config_filename(subsystem='vault').unlink(missing_ok=True)
... ...
@@ -1129,6 +1172,7 @@ class TestCLI:
1129 1172
         monkeypatch: pytest.MonkeyPatch,
1130 1173
         export_options: list[str],
1131 1174
     ) -> None:
1175
+        """Exporting an invalid config fails."""
1132 1176
         runner = click.testing.CliRunner(mix_stderr=False)
1133 1177
         with tests.isolated_vault_config(
1134 1178
             monkeypatch=monkeypatch, runner=runner, vault_config={}
... ...
@@ -1156,6 +1200,7 @@ class TestCLI:
1156 1200
         monkeypatch: pytest.MonkeyPatch,
1157 1201
         export_options: list[str],
1158 1202
     ) -> None:
1203
+        """Exporting an invalid config fails."""
1159 1204
         runner = click.testing.CliRunner(mix_stderr=False)
1160 1205
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1161 1206
             config_file = cli._config_filename(subsystem='vault')
... ...
@@ -1184,6 +1229,7 @@ class TestCLI:
1184 1229
         monkeypatch: pytest.MonkeyPatch,
1185 1230
         export_options: list[str],
1186 1231
     ) -> None:
1232
+        """Exporting an invalid config fails."""
1187 1233
         runner = click.testing.CliRunner(mix_stderr=False)
1188 1234
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1189 1235
             dname = cli._config_filename(subsystem=None)
... ...
@@ -1210,6 +1256,7 @@ class TestCLI:
1210 1256
         monkeypatch: pytest.MonkeyPatch,
1211 1257
         export_options: list[str],
1212 1258
     ) -> None:
1259
+        """Exporting an invalid config fails."""
1213 1260
         runner = click.testing.CliRunner(mix_stderr=False)
1214 1261
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1215 1262
             config_dir = cli._config_filename(subsystem=None)
... ...
@@ -1232,6 +1279,7 @@ class TestCLI:
1232 1279
     def test_220_edit_notes_successfully(
1233 1280
         self, monkeypatch: pytest.MonkeyPatch
1234 1281
     ) -> None:
1282
+        """Editing notes works."""
1235 1283
         edit_result = """
1236 1284
 
1237 1285
 # - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -
... ...
@@ -1263,6 +1311,7 @@ contents go here
1263 1311
     def test_221_edit_notes_noop(
1264 1312
         self, monkeypatch: pytest.MonkeyPatch
1265 1313
     ) -> None:
1314
+        """Abandoning edited notes works."""
1266 1315
         runner = click.testing.CliRunner(mix_stderr=False)
1267 1316
         with tests.isolated_vault_config(
1268 1317
             monkeypatch=monkeypatch,
... ...
@@ -1283,9 +1332,16 @@ contents go here
1283 1332
                 config = json.load(infile)
1284 1333
             assert config == {'global': {'phrase': 'abc'}, 'services': {}}
1285 1334
 
1335
+    # TODO(the-13th-letter): Keep this behavior or not, with or without
1336
+    # warning?
1286 1337
     def test_222_edit_notes_marker_removed(
1287 1338
         self, monkeypatch: pytest.MonkeyPatch
1288 1339
     ) -> None:
1340
+        """Removing the notes marker still saves the notes.
1341
+
1342
+        TODO: Keep this behavior or not, with or without warning?
1343
+
1344
+        """
1289 1345
         runner = click.testing.CliRunner(mix_stderr=False)
1290 1346
         with tests.isolated_vault_config(
1291 1347
             monkeypatch=monkeypatch,
... ...
@@ -1312,6 +1368,7 @@ contents go here
1312 1368
     def test_223_edit_notes_abort(
1313 1369
         self, monkeypatch: pytest.MonkeyPatch
1314 1370
     ) -> None:
1371
+        """Aborting editing notes works."""
1315 1372
         runner = click.testing.CliRunner(mix_stderr=False)
1316 1373
         with tests.isolated_vault_config(
1317 1374
             monkeypatch=monkeypatch,
... ...
@@ -1388,6 +1445,7 @@ contents go here
1388 1445
         input: str,
1389 1446
         result_config: Any,
1390 1447
     ) -> None:
1448
+        """Storing valid settings via `--config` works."""
1391 1449
         runner = click.testing.CliRunner(mix_stderr=False)
1392 1450
         with tests.isolated_vault_config(
1393 1451
             monkeypatch=monkeypatch,
... ...
@@ -1449,6 +1507,7 @@ contents go here
1449 1507
         input: str,
1450 1508
         err_text: str,
1451 1509
     ) -> None:
1510
+        """Storing invalid settings via `--config` fails."""
1452 1511
         runner = click.testing.CliRunner(mix_stderr=False)
1453 1512
         with tests.isolated_vault_config(
1454 1513
             monkeypatch=monkeypatch,
... ...
@@ -1473,6 +1532,7 @@ contents go here
1473 1532
         self,
1474 1533
         monkeypatch: pytest.MonkeyPatch,
1475 1534
     ) -> None:
1535
+        """Not selecting an SSH key during `--config --key` fails."""
1476 1536
         runner = click.testing.CliRunner(mix_stderr=False)
1477 1537
         with tests.isolated_vault_config(
1478 1538
             monkeypatch=monkeypatch,
... ...
@@ -1500,6 +1560,7 @@ contents go here
1500 1560
         monkeypatch: pytest.MonkeyPatch,
1501 1561
         skip_if_no_af_unix_support: None,
1502 1562
     ) -> None:
1563
+        """Not running an SSH agent during `--config --key` fails."""
1503 1564
         del skip_if_no_af_unix_support
1504 1565
         runner = click.testing.CliRunner(mix_stderr=False)
1505 1566
         with tests.isolated_vault_config(
... ...
@@ -1522,6 +1583,7 @@ contents go here
1522 1583
         self,
1523 1584
         monkeypatch: pytest.MonkeyPatch,
1524 1585
     ) -> None:
1586
+        """Not running a reachable SSH agent during `--config --key` fails."""
1525 1587
         runner = click.testing.CliRunner(mix_stderr=False)
1526 1588
         with tests.isolated_vault_config(
1527 1589
             monkeypatch=monkeypatch,
... ...
@@ -1546,6 +1608,7 @@ contents go here
1546 1608
         monkeypatch: pytest.MonkeyPatch,
1547 1609
         try_race_free_implementation: bool,
1548 1610
     ) -> None:
1611
+        """Using a read-only configuration file with `--config` fails."""
1549 1612
         runner = click.testing.CliRunner(mix_stderr=False)
1550 1613
         with tests.isolated_vault_config(
1551 1614
             monkeypatch=monkeypatch,
... ...
@@ -1570,6 +1633,7 @@ contents go here
1570 1633
         self,
1571 1634
         monkeypatch: pytest.MonkeyPatch,
1572 1635
     ) -> None:
1636
+        """OS-erroring with `--config` fails."""
1573 1637
         runner = click.testing.CliRunner(mix_stderr=False)
1574 1638
         with tests.isolated_vault_config(
1575 1639
             monkeypatch=monkeypatch,
... ...
@@ -1597,6 +1661,7 @@ contents go here
1597 1661
         self,
1598 1662
         monkeypatch: pytest.MonkeyPatch,
1599 1663
     ) -> None:
1664
+        """Issuing conflicting settings to `--config` fails."""
1600 1665
         runner = click.testing.CliRunner(mix_stderr=False)
1601 1666
         with tests.isolated_vault_config(
1602 1667
             monkeypatch=monkeypatch,
... ...
@@ -1624,6 +1689,7 @@ contents go here
1624 1689
         monkeypatch: pytest.MonkeyPatch,
1625 1690
         running_ssh_agent: tests.RunningSSHAgentInfo,
1626 1691
     ) -> None:
1692
+        """Not holding any SSH keys during `--config --key` fails."""
1627 1693
         del running_ssh_agent
1628 1694
         runner = click.testing.CliRunner(mix_stderr=False)
1629 1695
         with tests.isolated_vault_config(
... ...
@@ -1654,6 +1720,7 @@ contents go here
1654 1720
         monkeypatch: pytest.MonkeyPatch,
1655 1721
         running_ssh_agent: tests.RunningSSHAgentInfo,
1656 1722
     ) -> None:
1723
+        """The SSH agent erroring during `--config --key` fails."""
1657 1724
         del running_ssh_agent
1658 1725
         runner = click.testing.CliRunner(mix_stderr=False)
1659 1726
         with tests.isolated_vault_config(
... ...
@@ -1681,6 +1748,7 @@ contents go here
1681 1748
         monkeypatch: pytest.MonkeyPatch,
1682 1749
         running_ssh_agent: tests.RunningSSHAgentInfo,
1683 1750
     ) -> None:
1751
+        """The SSH agent refusing during `--config --key` fails."""
1684 1752
         del running_ssh_agent
1685 1753
         runner = click.testing.CliRunner(mix_stderr=False)
1686 1754
         with tests.isolated_vault_config(
... ...
@@ -1706,6 +1774,7 @@ contents go here
1706 1774
         )
1707 1775
 
1708 1776
     def test_226_no_arguments(self, monkeypatch: pytest.MonkeyPatch) -> None:
1777
+        """Calling `derivepassphrase vault` without any arguments fails."""
1709 1778
         runner = click.testing.CliRunner(mix_stderr=False)
1710 1779
         with tests.isolated_config(
1711 1780
             monkeypatch=monkeypatch,
... ...
@@ -1722,6 +1791,7 @@ contents go here
1722 1791
     def test_226a_no_passphrase_or_key(
1723 1792
         self, monkeypatch: pytest.MonkeyPatch
1724 1793
     ) -> None:
1794
+        """Deriving a passphrase without a passphrase or key fails."""
1725 1795
         runner = click.testing.CliRunner(mix_stderr=False)
1726 1796
         with tests.isolated_config(
1727 1797
             monkeypatch=monkeypatch,
... ...
@@ -1740,7 +1810,13 @@ contents go here
1740 1810
     def test_230_config_directory_nonexistant(
1741 1811
         self, monkeypatch: pytest.MonkeyPatch
1742 1812
     ) -> None:
1743
-        """https://github.com/the-13th-letter/derivepassphrase/issues/6"""
1813
+        """Running without an existing config directory works.
1814
+
1815
+        This is a regression test; see [issue\u00a0#6][] for context.
1816
+
1817
+        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
1818
+
1819
+        """
1744 1820
         runner = click.testing.CliRunner(mix_stderr=False)
1745 1821
         with tests.isolated_config(
1746 1822
             monkeypatch=monkeypatch,
... ...
@@ -1771,7 +1847,16 @@ contents go here
1771 1847
     def test_230a_config_directory_not_a_file(
1772 1848
         self, monkeypatch: pytest.MonkeyPatch
1773 1849
     ) -> None:
1774
-        """https://github.com/the-13th-letter/derivepassphrase/issues/6"""
1850
+        """Erroring without an existing config directory errors normally.
1851
+
1852
+        That is, the missing configuration directory does not cause any
1853
+        errors by itself.
1854
+
1855
+        This is a regression test; see [issue\u00a0#6][] for context.
1856
+
1857
+        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
1858
+
1859
+        """
1775 1860
         runner = click.testing.CliRunner(mix_stderr=False)
1776 1861
         with tests.isolated_config(
1777 1862
             monkeypatch=monkeypatch,
... ...
@@ -1802,6 +1887,7 @@ contents go here
1802 1887
     def test_230b_store_config_custom_error(
1803 1888
         self, monkeypatch: pytest.MonkeyPatch
1804 1889
     ) -> None:
1890
+        """Storing the configuration reacts even to weird errors."""
1805 1891
         runner = click.testing.CliRunner(mix_stderr=False)
1806 1892
         with tests.isolated_config(
1807 1893
             monkeypatch=monkeypatch,
... ...
@@ -1933,6 +2019,7 @@ contents go here
1933 2019
         input: str | None,
1934 2020
         warning_message: str,
1935 2021
     ) -> None:
2022
+        """Using unnormalized Unicode passphrases warns."""
1936 2023
         runner = click.testing.CliRunner(mix_stderr=False)
1937 2024
         with tests.isolated_vault_config(
1938 2025
             monkeypatch=monkeypatch,
... ...
@@ -2003,6 +2090,7 @@ contents go here
2003 2090
         input: str | None,
2004 2091
         error_message: str,
2005 2092
     ) -> None:
2093
+        """Using unknown Unicode normalization forms fails."""
2006 2094
         runner = click.testing.CliRunner(mix_stderr=False)
2007 2095
         with tests.isolated_vault_config(
2008 2096
             monkeypatch=monkeypatch,
... ...
@@ -2044,6 +2132,7 @@ contents go here
2044 2132
         monkeypatch: pytest.MonkeyPatch,
2045 2133
         command_line: list[str],
2046 2134
     ) -> None:
2135
+        """Using unknown Unicode normalization forms in the config fails."""
2047 2136
         runner = click.testing.CliRunner(mix_stderr=False)
2048 2137
         with tests.isolated_vault_config(
2049 2138
             monkeypatch=monkeypatch,
... ...
@@ -2077,6 +2166,7 @@ contents go here
2077 2166
         self,
2078 2167
         monkeypatch: pytest.MonkeyPatch,
2079 2168
     ) -> None:
2169
+        """Loading a user configuration file in an invalid format fails."""
2080 2170
         runner = click.testing.CliRunner(mix_stderr=False)
2081 2171
         with tests.isolated_vault_config(
2082 2172
             monkeypatch=monkeypatch,
... ...
@@ -2101,6 +2191,7 @@ contents go here
2101 2191
         self,
2102 2192
         monkeypatch: pytest.MonkeyPatch,
2103 2193
     ) -> None:
2194
+        """Querying the SSH agent without `AF_UNIX` support fails."""
2104 2195
         runner = click.testing.CliRunner(mix_stderr=False)
2105 2196
         with tests.isolated_vault_config(
2106 2197
             monkeypatch=monkeypatch,
... ...
@@ -2123,6 +2214,8 @@ contents go here
2123 2214
 
2124 2215
 
2125 2216
 class TestCLIUtils:
2217
+    """Tests for command-line utility functions."""
2218
+
2126 2219
     @pytest.mark.parametrize(
2127 2220
         'config',
2128 2221
         [
... ...
@@ -2145,6 +2238,7 @@ class TestCLIUtils:
2145 2238
     def test_100_load_config(
2146 2239
         self, monkeypatch: pytest.MonkeyPatch, config: Any
2147 2240
     ) -> None:
2241
+        """`cli._load_config` works for valid configurations."""
2148 2242
         runner = click.testing.CliRunner()
2149 2243
         with tests.isolated_vault_config(
2150 2244
             monkeypatch=monkeypatch, runner=runner, vault_config=config
... ...
@@ -2157,6 +2251,7 @@ class TestCLIUtils:
2157 2251
     def test_110_save_bad_config(
2158 2252
         self, monkeypatch: pytest.MonkeyPatch
2159 2253
     ) -> None:
2254
+        """`cli._save_config` fails for bad configurations."""
2160 2255
         runner = click.testing.CliRunner()
2161 2256
         # TODO(the-13th-letter): Rewrite using parenthesized
2162 2257
         # with-statements.
... ...
@@ -2173,6 +2268,7 @@ class TestCLIUtils:
2173 2268
             cli._save_config(None)  # type: ignore[arg-type]
2174 2269
 
2175 2270
     def test_111_prompt_for_selection_multiple(self) -> None:
2271
+        """`cli._prompt_for_selection` works in the "multiple" case."""
2176 2272
         @click.command()
2177 2273
         @click.option('--heading', default='Our menu:')
2178 2274
         @click.argument('items', nargs=-1)
... ...
@@ -2248,6 +2344,7 @@ Your selection? (1-10, leave empty to abort):\x20
2248 2344
         ), 'expected known output'
2249 2345
 
2250 2346
     def test_112_prompt_for_selection_single(self) -> None:
2347
+        """`cli._prompt_for_selection` works in the "single" case."""
2251 2348
         @click.command()
2252 2349
         @click.option('--item', default='baked beans')
2253 2350
         @click.argument('prompt')
... ...
@@ -2295,6 +2392,7 @@ Boo.
2295 2392
     def test_113_prompt_for_passphrase(
2296 2393
         self, monkeypatch: pytest.MonkeyPatch
2297 2394
     ) -> None:
2395
+        """`cli._prompt_for_passphrase` works."""
2298 2396
         monkeypatch.setattr(
2299 2397
             click,
2300 2398
             'prompt',
... ...
@@ -2315,6 +2413,12 @@ Boo.
2315 2413
         caplog: pytest.LogCaptureFixture,
2316 2414
         capsys: pytest.CaptureFixture[str],
2317 2415
     ) -> None:
2416
+        """The standard logging context manager works.
2417
+
2418
+        It registers its handlers, once, and emits formatted calls to
2419
+        standard error prefixed with the program name.
2420
+
2421
+        """
2318 2422
         prog_name = cli.StandardCLILogging.prog_name
2319 2423
         package_name = cli.StandardCLILogging.package_name
2320 2424
         logger = logging.getLogger(package_name)
... ...
@@ -2371,6 +2475,14 @@ Boo.
2371 2475
         caplog: pytest.LogCaptureFixture,
2372 2476
         capsys: pytest.CaptureFixture[str],
2373 2477
     ) -> None:
2478
+        """The standard warnings logging context manager works.
2479
+
2480
+        It registers its handlers, once, and emits formatted calls to
2481
+        standard error prefixed with the program name.  It also adheres
2482
+        to the global warnings filter concerning which messages it
2483
+        actually emits to standard error.
2484
+
2485
+        """
2374 2486
         warnings_cm = cli.StandardCLILogging.ensure_standard_warnings_logging()
2375 2487
         THE_FUTURE = 'the future will be here sooner than you think'  # noqa: N806
2376 2488
         JUST_TESTING = 'just testing whether warnings work'  # noqa: N806
... ...
@@ -2419,6 +2531,19 @@ Boo.
2419 2531
         self,
2420 2532
         config: Any,
2421 2533
     ) -> None:
2534
+        """Emits a config in sh(1) format, then reads it back to verify it.
2535
+
2536
+        This function exports the configuration, sets up a new
2537
+        enviroment, then calls
2538
+        [`vault_config_exporter_shell_interpreter`][] on the export
2539
+        script, verifying that each command ran successfully and that
2540
+        the final configuration matches the initial one.
2541
+
2542
+        Args:
2543
+            config:
2544
+                The configuration to emit and read back.
2545
+
2546
+        """
2422 2547
         prog_name_list = ('derivepassphrase', 'vault')
2423 2548
         with io.StringIO() as outfile:
2424 2549
             cli._print_config_as_sh_script(
... ...
@@ -2464,6 +2589,15 @@ Boo.
2464 2589
         global_config_settable: _types.VaultConfigServicesSettings,
2465 2590
         global_config_importable: _types.VaultConfigServicesSettings,
2466 2591
     ) -> None:
2592
+        """Exporting configurations as sh(1) script works.
2593
+
2594
+        Here, we check global-only configurations which use both
2595
+        settings settable via `--config` and settings requiring
2596
+        `--import`.
2597
+
2598
+        The actual verification is done by [`_export_as_sh_helper`][].
2599
+
2600
+        """
2467 2601
         config: _types.VaultConfig = {
2468 2602
             'global': global_config_settable | global_config_importable,
2469 2603
             'services': {},
... ...
@@ -2497,6 +2631,14 @@ Boo.
2497 2631
         self,
2498 2632
         global_config_importable: _types.VaultConfigServicesSettings,
2499 2633
     ) -> None:
2634
+        """Exporting configurations as sh(1) script works.
2635
+
2636
+        Here, we check global-only configurations which only use
2637
+        settings requiring `--import`.
2638
+
2639
+        The actual verification is done by [`_export_as_sh_helper`][].
2640
+
2641
+        """
2500 2642
         config: _types.VaultConfig = {
2501 2643
             'global': global_config_importable,
2502 2644
             'services': {},
... ...
@@ -2551,6 +2693,15 @@ Boo.
2551 2693
         service_config_settable: _types.VaultConfigServicesSettings,
2552 2694
         service_config_importable: _types.VaultConfigServicesSettings,
2553 2695
     ) -> None:
2696
+        """Exporting configurations as sh(1) script works.
2697
+
2698
+        Here, we check service-only configurations which use both
2699
+        settings settable via `--config` and settings requiring
2700
+        `--import`.
2701
+
2702
+        The actual verification is done by [`_export_as_sh_helper`][].
2703
+
2704
+        """
2554 2705
         config: _types.VaultConfig = {
2555 2706
             'services': {
2556 2707
                 service_name: (
... ...
@@ -2604,6 +2755,14 @@ Boo.
2604 2755
         service_name: str,
2605 2756
         service_config_importable: _types.VaultConfigServicesSettings,
2606 2757
     ) -> None:
2758
+        """Exporting configurations as sh(1) script works.
2759
+
2760
+        Here, we check service-only configurations which only use
2761
+        settings requiring `--import`.
2762
+
2763
+        The actual verification is done by [`_export_as_sh_helper`][].
2764
+
2765
+        """
2607 2766
         config: _types.VaultConfig = {
2608 2767
             'services': {
2609 2768
                 service_name: service_config_importable,
... ...
@@ -2649,6 +2808,7 @@ Boo.
2649 2808
         config: _types.VaultConfig,
2650 2809
         result_config: _types.VaultConfig,
2651 2810
     ) -> None:
2811
+        """Repeatedly removing the same parts of a configuration works."""
2652 2812
         runner = click.testing.CliRunner(mix_stderr=False)
2653 2813
         for start_config in [config, result_config]:
2654 2814
             with tests.isolated_vault_config(
... ...
@@ -2672,6 +2832,7 @@ Boo.
2672 2832
                 assert config_readback == result_config
2673 2833
 
2674 2834
     def test_204_phrase_from_key_manually(self) -> None:
2835
+        """The dummy service, key and config settings are consistent."""
2675 2836
         assert (
2676 2837
             vault.Vault(
2677 2838
                 phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS
... ...
@@ -2691,6 +2852,7 @@ Boo.
2691 2852
         vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
2692 2853
         input: int,
2693 2854
     ) -> None:
2855
+        """Command-line argument constraint validation works."""
2694 2856
         ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, [])
2695 2857
         param = cli.derivepassphrase_vault.params[0]
2696 2858
         assert vfunc(ctx, param, input) == input
... ...
@@ -2702,6 +2864,7 @@ Boo.
2702 2864
         running_ssh_agent: tests.RunningSSHAgentInfo,
2703 2865
         conn_hint: str,
2704 2866
     ) -> None:
2867
+        """`cli._get_suitable_ssh_keys` works."""
2705 2868
         with monkeypatch.context():
2706 2869
             monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
2707 2870
             monkeypatch.setattr(
... ...
@@ -2737,6 +2900,8 @@ Boo.
2737 2900
         skip_if_no_af_unix_support: None,
2738 2901
         ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
2739 2902
     ) -> None:
2903
+        """All errors in `cli._key_to_phrase` are handled."""
2904
+
2740 2905
         class ErrCallback(BaseException):
2741 2906
             def __init__(self, *args: Any, **kwargs: Any) -> None:
2742 2907
                 super().__init__(*args[:1])
... ...
@@ -2818,7 +2983,10 @@ Boo.
2818 2983
 # TODO(the-13th-letter): Remove this class in v1.0.
2819 2984
 # https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0
2820 2985
 class TestCLITransition:
2986
+    """Transition tests for the command-line interface up to v1.0."""
2987
+
2821 2988
     def test_100_help_output(self, monkeypatch: pytest.MonkeyPatch) -> None:
2989
+        """The top-level help text mentions subcommands."""
2822 2990
         runner = click.testing.CliRunner(mix_stderr=False)
2823 2991
         with tests.isolated_config(
2824 2992
             monkeypatch=monkeypatch,
... ...
@@ -2835,6 +3003,7 @@ class TestCLITransition:
2835 3003
     def test_101_help_output_export(
2836 3004
         self, monkeypatch: pytest.MonkeyPatch
2837 3005
     ) -> None:
3006
+        """The "export" subcommand help text mentions subcommands."""
2838 3007
         runner = click.testing.CliRunner(mix_stderr=False)
2839 3008
         with tests.isolated_config(
2840 3009
             monkeypatch=monkeypatch,
... ...
@@ -2853,6 +3022,7 @@ class TestCLITransition:
2853 3022
     def test_102_help_output_export_vault(
2854 3023
         self, monkeypatch: pytest.MonkeyPatch
2855 3024
     ) -> None:
3025
+        """The "export vault" subcommand help text has known content."""
2856 3026
         runner = click.testing.CliRunner(mix_stderr=False)
2857 3027
         with tests.isolated_config(
2858 3028
             monkeypatch=monkeypatch,
... ...
@@ -2871,6 +3041,7 @@ class TestCLITransition:
2871 3041
     def test_103_help_output_vault(
2872 3042
         self, monkeypatch: pytest.MonkeyPatch
2873 3043
     ) -> None:
3044
+        """The "vault" subcommand help text has known content."""
2874 3045
         runner = click.testing.CliRunner(mix_stderr=False)
2875 3046
         with tests.isolated_config(
2876 3047
             monkeypatch=monkeypatch,
... ...
@@ -2911,6 +3082,7 @@ class TestCLITransition:
2911 3082
     def test_110_load_config_backup(
2912 3083
         self, monkeypatch: pytest.MonkeyPatch, config: Any
2913 3084
     ) -> None:
3085
+        """Loading the old settings file works."""
2914 3086
         runner = click.testing.CliRunner()
2915 3087
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
2916 3088
             cli._config_filename(subsystem='old settings.json').write_text(
... ...
@@ -2940,6 +3112,7 @@ class TestCLITransition:
2940 3112
     def test_111_migrate_config(
2941 3113
         self, monkeypatch: pytest.MonkeyPatch, config: Any
2942 3114
     ) -> None:
3115
+        """Migrating the old settings file works."""
2943 3116
         runner = click.testing.CliRunner()
2944 3117
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
2945 3118
             cli._config_filename(subsystem='old settings.json').write_text(
... ...
@@ -2969,6 +3142,7 @@ class TestCLITransition:
2969 3142
     def test_112_migrate_config_error(
2970 3143
         self, monkeypatch: pytest.MonkeyPatch, config: Any
2971 3144
     ) -> None:
3145
+        """Migrating the old settings file atop a directory fails."""
2972 3146
         runner = click.testing.CliRunner()
2973 3147
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
2974 3148
             cli._config_filename(subsystem='old settings.json').write_text(
... ...
@@ -3004,6 +3178,7 @@ class TestCLITransition:
3004 3178
     def test_113_migrate_config_error_bad_config_value(
3005 3179
         self, monkeypatch: pytest.MonkeyPatch, config: Any
3006 3180
     ) -> None:
3181
+        """Migrating an invalid old settings file fails."""
3007 3182
         runner = click.testing.CliRunner()
3008 3183
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
3009 3184
             cli._config_filename(subsystem='old settings.json').write_text(
... ...
@@ -3017,6 +3192,7 @@ class TestCLITransition:
3017 3192
         monkeypatch: pytest.MonkeyPatch,
3018 3193
         caplog: pytest.LogCaptureFixture,
3019 3194
     ) -> None:
3195
+        """Forwarding arguments from "export" to "export vault" works."""
3020 3196
         pytest.importorskip('cryptography', minversion='38.0')
3021 3197
         runner = click.testing.CliRunner(mix_stderr=False)
3022 3198
         with tests.isolated_vault_exporter_config(
... ...
@@ -3045,6 +3221,7 @@ class TestCLITransition:
3045 3221
         monkeypatch: pytest.MonkeyPatch,
3046 3222
         caplog: pytest.LogCaptureFixture,
3047 3223
     ) -> None:
3224
+        """Deferring from "export" to "export vault" works."""
3048 3225
         pytest.importorskip('cryptography', minversion='38.0')
3049 3226
         runner = click.testing.CliRunner(mix_stderr=False)
3050 3227
         with tests.isolated_config(
... ...
@@ -3075,6 +3252,7 @@ class TestCLITransition:
3075 3252
         caplog: pytest.LogCaptureFixture,
3076 3253
         charset_name: str,
3077 3254
     ) -> None:
3255
+        """Forwarding arguments from top-level to "vault" works."""
3078 3256
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
3079 3257
         option = f'--{charset_name}'
3080 3258
         charset = vault.Vault._CHARSETS[charset_name].decode('ascii')
... ...
@@ -3107,6 +3285,7 @@ class TestCLITransition:
3107 3285
         monkeypatch: pytest.MonkeyPatch,
3108 3286
         caplog: pytest.LogCaptureFixture,
3109 3287
     ) -> None:
3288
+        """Deferring from top-level to "vault" works."""
3110 3289
         runner = click.testing.CliRunner(mix_stderr=False)
3111 3290
         with tests.isolated_config(
3112 3291
             monkeypatch=monkeypatch,
... ...
@@ -3134,6 +3313,7 @@ class TestCLITransition:
3134 3313
         monkeypatch: pytest.MonkeyPatch,
3135 3314
         caplog: pytest.LogCaptureFixture,
3136 3315
     ) -> None:
3316
+        """Exporting from (and migrating) the old settings file works."""
3137 3317
         caplog.set_level(logging.INFO)
3138 3318
         runner = click.testing.CliRunner(mix_stderr=False)
3139 3319
         with tests.isolated_config(
... ...
@@ -3167,6 +3347,7 @@ class TestCLITransition:
3167 3347
         monkeypatch: pytest.MonkeyPatch,
3168 3348
         caplog: pytest.LogCaptureFixture,
3169 3349
     ) -> None:
3350
+        """Exporting from (and not migrating) the old settings file fails."""
3170 3351
         runner = click.testing.CliRunner(mix_stderr=False)
3171 3352
         with tests.isolated_config(
3172 3353
             monkeypatch=monkeypatch,
... ...
@@ -3208,6 +3389,7 @@ class TestCLITransition:
3208 3389
         self,
3209 3390
         monkeypatch: pytest.MonkeyPatch,
3210 3391
     ) -> None:
3392
+        """Completing service names from the old settings file works."""
3211 3393
         runner = click.testing.CliRunner(mix_stderr=False)
3212 3394
         config = {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}
3213 3395
         with tests.isolated_vault_config(
... ...
@@ -3227,6 +3409,7 @@ class TestCLITransition:
3227 3409
 
3228 3410
 
3229 3411
 _known_services = (DUMMY_SERVICE, 'email', 'bank', 'work')
3412
+"""Known service names.  Used for the [`ConfigManagementStateMachine`][]."""
3230 3413
 _valid_properties = (
3231 3414
     'length',
3232 3415
     'repeat',
... ...
@@ -3237,12 +3420,22 @@ _valid_properties = (
3237 3420
     'dash',
3238 3421
     'symbol',
3239 3422
 )
3423
+"""Known vault properties.  Used for the [`ConfigManagementStateMachine`][]."""
3240 3424
 
3241 3425
 
3242 3426
 def _build_reduced_vault_config_settings(
3243 3427
     config: _types.VaultConfigServicesSettings,
3244 3428
     keys_to_purge: frozenset[str],
3245 3429
 ) -> _types.VaultConfigServicesSettings:
3430
+    """Return a service settings object with certain keys pruned.
3431
+
3432
+    Args:
3433
+        config:
3434
+            The original service settings object.
3435
+        keys_to_purge:
3436
+            The keys to purge from the settings object.
3437
+
3438
+    """
3246 3439
     config2 = copy.deepcopy(config)
3247 3440
     for key in keys_to_purge:
3248 3441
         config2.pop(key, None)  # type: ignore[misc]
... ...
@@ -3257,12 +3450,14 @@ _services_strategy = strategies.builds(
3257 3450
         max_size=7,
3258 3451
     ),
3259 3452
 )
3453
+"""A hypothesis strategy to build incomplete service configurations."""
3260 3454
 
3261 3455
 
3262 3456
 def _assemble_config(
3263 3457
     global_data: _types.VaultConfigGlobalSettings,
3264 3458
     service_data: list[tuple[str, _types.VaultConfigServicesSettings]],
3265 3459
 ) -> _types.VaultConfig:
3460
+    """Return a vault config using the global and service data."""
3266 3461
     services_dict = dict(service_data)
3267 3462
     return (
3268 3463
         {'global': global_data, 'services': services_dict}
... ...
@@ -3276,6 +3471,21 @@ def _draw_service_name_and_data(
3276 3471
     draw: hypothesis.strategies.DrawFn,
3277 3472
     num_entries: int,
3278 3473
 ) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]:
3474
+    """Draw a service name and settings, as a hypothesis strategy.
3475
+
3476
+    Will draw service names from [`_known_services`][] and service
3477
+    settings via [`_services_strategy`][].
3478
+
3479
+    Args:
3480
+        draw:
3481
+            The `draw` function, as provided for by hypothesis.
3482
+        num_entries:
3483
+            The number of services to draw.
3484
+
3485
+    Returns:
3486
+        A sequence of pairs of service names and service settings.
3487
+
3488
+    """
3279 3489
     possible_services = list(_known_services)
3280 3490
     selected_services: list[str] = []
3281 3491
     for _ in range(num_entries):
... ...
@@ -3296,11 +3506,26 @@ _vault_full_config = strategies.builds(
3296 3506
         max_value=4,
3297 3507
     ).flatmap(_draw_service_name_and_data),
3298 3508
 )
3509
+"""A hypothesis strategy to build full vault configurations."""
3299 3510
 
3300 3511
 
3301 3512
 @tests.hypothesis_settings_coverage_compatible
3302 3513
 class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3514
+    """A state machine recording changes in the vault configuration.
3515
+
3516
+    Record possible configuration states in bundles, then in each rule,
3517
+    take a configuration and manipulate it somehow.
3518
+
3519
+    Attributes:
3520
+        setting:
3521
+            A bundle for single-service settings.
3522
+        configuration:
3523
+            A bundle for full vault configurations.
3524
+
3525
+    """
3526
+
3303 3527
     def __init__(self) -> None:
3528
+        """Initialize self, set up context managers and enter them."""
3304 3529
         super().__init__()
3305 3530
         self.runner = click.testing.CliRunner(mix_stderr=False)
3306 3531
         self.exit_stack = contextlib.ExitStack().__enter__()
... ...
@@ -3315,8 +3540,14 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3315 3540
             )
3316 3541
         )
3317 3542
 
3318
-    setting = stateful.Bundle('setting')
3319
-    configuration = stateful.Bundle('configuration')
3543
+    setting: stateful.Bundle[_types.VaultConfigServicesSettings] = (
3544
+        stateful.Bundle('setting')
3545
+    )
3546
+    """"""
3547
+    configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle(
3548
+        'configuration'
3549
+    )
3550
+    """"""
3320 3551
 
3321 3552
     @stateful.initialize(
3322 3553
         target=configuration,
... ...
@@ -3329,7 +3560,8 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3329 3560
     def declare_initial_configs(
3330 3561
         self,
3331 3562
         configs: Iterable[_types.VaultConfig],
3332
-    ) -> Iterable[_types.VaultConfig]:
3563
+    ) -> stateful.MultipleResults[_types.VaultConfig]:
3564
+        """Initialize the configuration bundle with eight configurations."""
3333 3565
         return stateful.multiple(*configs)
3334 3566
 
3335 3567
     @stateful.initialize(
... ...
@@ -3343,7 +3575,8 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3343 3575
     def extract_initial_settings(
3344 3576
         self,
3345 3577
         configs: list[_types.VaultConfig],
3346
-    ) -> Iterable[_types.VaultConfigServicesSettings]:
3578
+    ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]:
3579
+        """Initialize the settings bundle with four service settings."""
3347 3580
         settings: list[_types.VaultConfigServicesSettings] = []
3348 3581
         for c in configs:
3349 3582
             settings.extend(c['services'].values())
... ...
@@ -3381,6 +3614,26 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3381 3614
         maybe_unset: set[str],
3382 3615
         overwrite: bool,
3383 3616
     ) -> _types.VaultConfig:
3617
+        """Set the global settings of a configuration.
3618
+
3619
+        Args:
3620
+            config:
3621
+                The configuration to edit.
3622
+            setting:
3623
+                The new global settings.
3624
+            maybe_unset:
3625
+                Settings keys to additionally unset, if not already
3626
+                present in the new settings.  Corresponds to the
3627
+                `--unset` command-line argument.
3628
+            overwrite:
3629
+                Overwrite the settings object if true, or merge if
3630
+                false.  Corresponds to the `--overwrite-existing` and
3631
+                `--merge-existing` command-line arguments.
3632
+
3633
+        Returns:
3634
+            The amended configuration.
3635
+
3636
+        """
3384 3637
         cli._save_config(config)
3385 3638
         config_global = config.get('global', {})
3386 3639
         maybe_unset = set(maybe_unset) - setting.keys()
... ...
@@ -3432,6 +3685,28 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3432 3685
         maybe_unset: set[str],
3433 3686
         overwrite: bool,
3434 3687
     ) -> _types.VaultConfig:
3688
+        """Set the named service settings for a configuration.
3689
+
3690
+        Args:
3691
+            config:
3692
+                The configuration to edit.
3693
+            service:
3694
+                The name of the service to set.
3695
+            setting:
3696
+                The new service settings.
3697
+            maybe_unset:
3698
+                Settings keys to additionally unset, if not already
3699
+                present in the new settings.  Corresponds to the
3700
+                `--unset` command-line argument.
3701
+            overwrite:
3702
+                Overwrite the settings object if true, or merge if
3703
+                false.  Corresponds to the `--overwrite-existing` and
3704
+                `--merge-existing` command-line arguments.
3705
+
3706
+        Returns:
3707
+            The amended configuration.
3708
+
3709
+        """
3435 3710
         cli._save_config(config)
3436 3711
         config_service = config['services'].get(service, {})
3437 3712
         maybe_unset = set(maybe_unset) - setting.keys()
... ...
@@ -3473,6 +3748,16 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3473 3748
         self,
3474 3749
         config: _types.VaultConfig,
3475 3750
     ) -> _types.VaultConfig:
3751
+        """Purge the globals of a configuration.
3752
+
3753
+        Args:
3754
+            config:
3755
+                The configuration to edit.
3756
+
3757
+        Returns:
3758
+            The pruned configuration.
3759
+
3760
+        """
3476 3761
         cli._save_config(config)
3477 3762
         config.pop('global', None)
3478 3763
         result_ = self.runner.invoke(
... ...
@@ -3501,6 +3786,17 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3501 3786
         self,
3502 3787
         config_and_service: tuple[_types.VaultConfig, str],
3503 3788
     ) -> _types.VaultConfig:
3789
+        """Purge the settings of a named service in a configuration.
3790
+
3791
+        Args:
3792
+            config_and_service:
3793
+                A 2-tuple containing the configuration to edit, and the
3794
+                service name to purge.
3795
+
3796
+        Returns:
3797
+            The pruned configuration.
3798
+
3799
+        """
3504 3800
         config, service = config_and_service
3505 3801
         cli._save_config(config)
3506 3802
         config['services'].pop(service, None)
... ...
@@ -3523,6 +3819,16 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3523 3819
         self,
3524 3820
         config: _types.VaultConfig,
3525 3821
     ) -> _types.VaultConfig:
3822
+        """Purge the entire configuration.
3823
+
3824
+        Args:
3825
+            config:
3826
+                The configuration to edit.
3827
+
3828
+        Returns:
3829
+            The empty configuration.
3830
+
3831
+        """
3526 3832
         cli._save_config(config)
3527 3833
         config = {'services': {}}
3528 3834
         result_ = self.runner.invoke(
... ...
@@ -3548,6 +3854,22 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3548 3854
         config_to_import: _types.VaultConfig,
3549 3855
         overwrite: bool,
3550 3856
     ) -> _types.VaultConfig:
3857
+        """Import the given configuration into a base configuration.
3858
+
3859
+        Args:
3860
+            base_config:
3861
+                The configuration to import into.
3862
+            config_to_import:
3863
+                The configuration to import.
3864
+            overwrite:
3865
+                Overwrite the base configuration if true, or merge if
3866
+                false.  Corresponds to the `--overwrite-existing` and
3867
+                `--merge-existing` command-line arguments.
3868
+
3869
+        Returns:
3870
+            The imported or merged configuration.
3871
+
3872
+        """
3551 3873
         cli._save_config(base_config)
3552 3874
         config = (
3553 3875
             self.fold_configs(config_to_import, base_config)
... ...
@@ -3569,13 +3891,20 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
3569 3891
         return config
3570 3892
 
3571 3893
     def teardown(self) -> None:
3894
+        """Upon teardown, exit all contexts entered in `__init__`."""
3572 3895
         self.exit_stack.close()
3573 3896
 
3574 3897
 
3575 3898
 TestConfigManagement = ConfigManagementStateMachine.TestCase
3899
+"""The [`unittest.TestCase`][] class that will actually be run."""
3576 3900
 
3577 3901
 
3578 3902
 def bash_format(item: click.shell_completion.CompletionItem) -> str:
3903
+    """A formatter for `bash`-style shell completion items.
3904
+
3905
+    The format is `type,value`, and is dictated by [`click`][].
3906
+
3907
+    """
3579 3908
     type, value = (  # noqa: A001
3580 3909
         item.type,
3581 3910
         item.value,
... ...
@@ -3584,6 +3913,11 @@ def bash_format(item: click.shell_completion.CompletionItem) -> str:
3584 3913
 
3585 3914
 
3586 3915
 def fish_format(item: click.shell_completion.CompletionItem) -> str:
3916
+    r"""A formatter for `fish`-style shell completion items.
3917
+
3918
+    The format is `type,value<tab>help`, and is dictated by [`click`][].
3919
+
3920
+    """
3587 3921
     type, value, help = (  # noqa: A001
3588 3922
         item.type,
3589 3923
         item.value,
... ...
@@ -3593,6 +3927,19 @@ def fish_format(item: click.shell_completion.CompletionItem) -> str:
3593 3927
 
3594 3928
 
3595 3929
 def zsh_format(item: click.shell_completion.CompletionItem) -> str:
3930
+    r"""A formatter for `zsh`-style shell completion items.
3931
+
3932
+    The format is `type<newline>value<newline>help<newline>`, and is
3933
+    dictated by [`click`][].  Upstream `click` currently (v8.2.0) does
3934
+    not deal with colons in the value correctly when the help text is
3935
+    non-degenerate.  Our formatter here does, provided the upstream
3936
+    `zsh` completion script is used; see the [`cli.ZshComplete`][]
3937
+    class.  A request is underway to merge this change into upstream
3938
+    `click`; see [`pallets/click#2846`][PR2846].
3939
+
3940
+    [PR2846]: https://github.com/pallets/click/pull/2846
3941
+
3942
+    """
3596 3943
     empty_help = '_'
3597 3944
     help_, value = (
3598 3945
         (item.help, item.value.replace(':', r'\:'))
... ...
@@ -3605,6 +3952,7 @@ def zsh_format(item: click.shell_completion.CompletionItem) -> str:
3605 3952
 def completion_item(
3606 3953
     item: str | click.shell_completion.CompletionItem,
3607 3954
 ) -> click.shell_completion.CompletionItem:
3955
+    """Convert a string to a completion item, if necessary."""
3608 3956
     return (
3609 3957
         click.shell_completion.CompletionItem(item, type='plain')
3610 3958
         if isinstance(item, str)
... ...
@@ -3615,21 +3963,41 @@ def completion_item(
3615 3963
 def assertable_item(
3616 3964
     item: str | click.shell_completion.CompletionItem,
3617 3965
 ) -> tuple[str, Any, str | None]:
3966
+    """Convert a completion item into a pretty-printable item.
3967
+
3968
+    Intended to make completion items introspectable in pytest's
3969
+    `assert` output.
3970
+
3971
+    """
3618 3972
     item = completion_item(item)
3619 3973
     return (item.type, item.value, item.help)
3620 3974
 
3621 3975
 
3622 3976
 class TestShellCompletion:
3977
+    """Tests for the shell completion machinery."""
3978
+
3623 3979
     class Completions:
3980
+        """A deferred completion call."""
3981
+
3624 3982
         def __init__(
3625 3983
             self,
3626 3984
             args: Sequence[str],
3627 3985
             incomplete: str,
3628 3986
         ) -> None:
3987
+            """Initialize the object.
3988
+
3989
+            Args:
3990
+                args:
3991
+                    The sequence of complete command-line arguments.
3992
+                incomplete:
3993
+                    The final, incomplete, partial argument.
3994
+
3995
+            """
3629 3996
             self.args = tuple(args)
3630 3997
             self.incomplete = incomplete
3631 3998
 
3632 3999
         def __call__(self) -> Sequence[click.shell_completion.CompletionItem]:
4000
+            """Return the completion items."""
3633 4001
             args = list(self.args)
3634 4002
             completion = click.shell_completion.ShellComplete(
3635 4003
                 cli=cli.derivepassphrase,
... ...
@@ -3640,6 +4008,7 @@ class TestShellCompletion:
3640 4008
             return completion.get_completions(args, self.incomplete)
3641 4009
 
3642 4010
         def get_words(self) -> Sequence[str]:
4011
+            """Return the completion items' values, as a sequence."""
3643 4012
             return tuple(c.value for c in self())
3644 4013
 
3645 4014
     @pytest.mark.parametrize(
... ...
@@ -3661,6 +4030,7 @@ class TestShellCompletion:
3661 4030
         partial: str,
3662 4031
         is_completable: bool,
3663 4032
     ) -> None:
4033
+        """Our `_is_completable_item` predicate for service names works."""
3664 4034
         assert cli._is_completable_item(partial) == is_completable
3665 4035
 
3666 4036
     @pytest.mark.parametrize(
... ...
@@ -3769,6 +4139,7 @@ class TestShellCompletion:
3769 4139
         incomplete: str,
3770 4140
         completions: AbstractSet[str],
3771 4141
     ) -> None:
4142
+        """Our completion machinery works for all commands' options."""
3772 4143
         comp = self.Completions(command_prefix, incomplete)
3773 4144
         assert frozenset(comp.get_words()) == completions
3774 4145
 
... ...
@@ -3795,6 +4166,7 @@ class TestShellCompletion:
3795 4166
         incomplete: str,
3796 4167
         completions: AbstractSet[str],
3797 4168
     ) -> None:
4169
+        """Our completion machinery works for all commands' subcommands."""
3798 4170
         comp = self.Completions(command_prefix, incomplete)
3799 4171
         assert frozenset(comp.get_words()) == completions
3800 4172
 
... ...
@@ -3821,6 +4193,7 @@ class TestShellCompletion:
3821 4193
         command_prefix: Sequence[str],
3822 4194
         incomplete: str,
3823 4195
     ) -> None:
4196
+        """Our completion machinery works for all commands' paths."""
3824 4197
         file = click.shell_completion.CompletionItem('', type='file')
3825 4198
         completions = frozenset({(file.type, file.value, file.help)})
3826 4199
         comp = self.Completions(command_prefix, incomplete)
... ...
@@ -3870,6 +4243,7 @@ class TestShellCompletion:
3870 4243
         incomplete: str,
3871 4244
         completions: AbstractSet[str],
3872 4245
     ) -> None:
4246
+        """Our completion machinery works for vault service names."""
3873 4247
         runner = click.testing.CliRunner(mix_stderr=False)
3874 4248
         with tests.isolated_vault_config(
3875 4249
             monkeypatch=monkeypatch,
... ...
@@ -3994,6 +4368,7 @@ class TestShellCompletion:
3994 4368
         incomplete: str,
3995 4369
         results: list[str | click.shell_completion.CompletionItem],
3996 4370
     ) -> None:
4371
+        """Custom completion functions work for all shells."""
3997 4372
         runner = click.testing.CliRunner(mix_stderr=False)
3998 4373
         with tests.isolated_vault_config(
3999 4374
             monkeypatch=monkeypatch,
... ...
@@ -4196,6 +4571,7 @@ class TestShellCompletion:
4196 4571
         incomplete: str,
4197 4572
         completions: AbstractSet[str],
4198 4573
     ) -> None:
4574
+        """Completion skips incompletable items."""
4199 4575
         runner = click.testing.CliRunner(mix_stderr=False)
4200 4576
         vault_config = config if mode == 'config' else {'services': {}}
4201 4577
         with tests.isolated_vault_config(
... ...
@@ -4232,6 +4608,7 @@ class TestShellCompletion:
4232 4608
         self,
4233 4609
         monkeypatch: pytest.MonkeyPatch,
4234 4610
     ) -> None:
4611
+        """Service name completion quietly fails on missing configuration."""
4235 4612
         runner = click.testing.CliRunner(mix_stderr=False)
4236 4613
         with tests.isolated_vault_config(
4237 4614
             monkeypatch=monkeypatch,
... ...
@@ -4251,6 +4628,7 @@ class TestShellCompletion:
4251 4628
         monkeypatch: pytest.MonkeyPatch,
4252 4629
         exc_type: type[Exception],
4253 4630
     ) -> None:
4631
+        """Service name completion quietly fails on configuration errors."""
4254 4632
         runner = click.testing.CliRunner(mix_stderr=False)
4255 4633
         with tests.isolated_vault_config(
4256 4634
             monkeypatch=monkeypatch,
... ...
@@ -40,7 +40,16 @@ if TYPE_CHECKING:
40 40
 
41 41
 
42 42
 class TestCLI:
43
+    """Test the command-line interface for `derivepassphrase export vault`."""
44
+
43 45
     def test_200_path_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
46
+        """The path `VAULT_PATH` is supported.
47
+
48
+        Using `VAULT_PATH` as the path looks up the actual path in the
49
+        `VAULT_PATH` environment variable.  See
50
+        [`exporter.get_vault_path`][] for details.
51
+
52
+        """
44 53
         runner = click.testing.CliRunner(mix_stderr=False)
45 54
         with tests.isolated_vault_exporter_config(
46 55
             monkeypatch=monkeypatch,
... ...
@@ -58,6 +67,7 @@ class TestCLI:
58 67
         assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
59 68
 
60 69
     def test_201_key_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
70
+        """The `--key` option is supported."""
61 71
         runner = click.testing.CliRunner(mix_stderr=False)
62 72
         with tests.isolated_vault_exporter_config(
63 73
             monkeypatch=monkeypatch,
... ...
@@ -102,6 +112,12 @@ class TestCLI:
102 112
         config: str | bytes,
103 113
         config_data: dict[str, Any],
104 114
     ) -> None:
115
+        """Passing a specific format works.
116
+
117
+        Passing a specific format name causes `derivepassphrase export
118
+        vault` to only attempt decoding in that named format.
119
+
120
+        """
105 121
         runner = click.testing.CliRunner(mix_stderr=False)
106 122
         with tests.isolated_vault_exporter_config(
107 123
             monkeypatch=monkeypatch,
... ...
@@ -124,6 +140,7 @@ class TestCLI:
124 140
         monkeypatch: pytest.MonkeyPatch,
125 141
         caplog: pytest.LogCaptureFixture,
126 142
     ) -> None:
143
+        """Fail when trying to decode non-existant files/directories."""
127 144
         runner = click.testing.CliRunner(mix_stderr=False)
128 145
         with tests.isolated_vault_exporter_config(
129 146
             monkeypatch=monkeypatch,
... ...
@@ -150,6 +167,7 @@ class TestCLI:
150 167
         monkeypatch: pytest.MonkeyPatch,
151 168
         caplog: pytest.LogCaptureFixture,
152 169
     ) -> None:
170
+        """Fail to parse invalid vault configurations (files)."""
153 171
         runner = click.testing.CliRunner(mix_stderr=False)
154 172
         with tests.isolated_vault_exporter_config(
155 173
             monkeypatch=monkeypatch,
... ...
@@ -173,6 +191,7 @@ class TestCLI:
173 191
         monkeypatch: pytest.MonkeyPatch,
174 192
         caplog: pytest.LogCaptureFixture,
175 193
     ) -> None:
194
+        """Fail to parse invalid vault configurations (directories)."""
176 195
         runner = click.testing.CliRunner(mix_stderr=False)
177 196
         with tests.isolated_vault_exporter_config(
178 197
             monkeypatch=monkeypatch,
... ...
@@ -199,6 +218,7 @@ class TestCLI:
199 218
         monkeypatch: pytest.MonkeyPatch,
200 219
         caplog: pytest.LogCaptureFixture,
201 220
     ) -> None:
221
+        """Fail to parse vault configurations with invalid integrity checks."""
202 222
         runner = click.testing.CliRunner(mix_stderr=False)
203 223
         with tests.isolated_vault_exporter_config(
204 224
             monkeypatch=monkeypatch,
... ...
@@ -222,6 +242,7 @@ class TestCLI:
222 242
         monkeypatch: pytest.MonkeyPatch,
223 243
         caplog: pytest.LogCaptureFixture,
224 244
     ) -> None:
245
+        """The decoded vault configuration data is valid."""
225 246
         runner = click.testing.CliRunner(mix_stderr=False)
226 247
         with tests.isolated_vault_exporter_config(
227 248
             monkeypatch=monkeypatch,
... ...
@@ -251,6 +272,8 @@ class TestCLI:
251 272
 
252 273
 
253 274
 class TestStoreroom:
275
+    """Test the "storeroom" handler and handler machinery."""
276
+
254 277
     @pytest.mark.parametrize('path', ['.vault', None])
255 278
     @pytest.mark.parametrize(
256 279
         'key',
... ...
@@ -282,6 +305,12 @@ class TestStoreroom:
282 305
         key: str | Buffer | None,
283 306
         handler: exporter.ExportVaultConfigDataFunction,
284 307
     ) -> None:
308
+        """Support different argument types.
309
+
310
+        The [`exporter.export_vault_config_data`][] dispatcher supports
311
+        them as well.
312
+
313
+        """
285 314
         runner = click.testing.CliRunner(mix_stderr=False)
286 315
         with tests.isolated_vault_exporter_config(
287 316
             monkeypatch=monkeypatch,
... ...
@@ -295,6 +324,7 @@ class TestStoreroom:
295 324
             )
296 325
 
297 326
     def test_400_decrypt_bucket_item_unknown_version(self) -> None:
327
+        """Fail on unknown versions of the master keys file."""
298 328
         bucket_item = (
299 329
             b'\xff' + bytes(storeroom.ENCRYPTED_KEYPAIR_SIZE) + bytes(3)
300 330
         )
... ...
@@ -312,6 +342,12 @@ class TestStoreroom:
312 342
         monkeypatch: pytest.MonkeyPatch,
313 343
         config: str,
314 344
     ) -> None:
345
+        """Fail on bad or unsupported bucket file contents.
346
+
347
+        These include unknown versions, invalid JSON, or JSON of the
348
+        wrong shape.
349
+
350
+        """
315 351
         runner = click.testing.CliRunner(mix_stderr=False)
316 352
         master_keys = _types.StoreroomMasterKeys(
317 353
             encryption_key=bytes(storeroom.KEY_SIZE),
... ...
@@ -363,6 +399,11 @@ class TestStoreroom:
363 399
         err_msg: str,
364 400
         handler: exporter.ExportVaultConfigDataFunction,
365 401
     ) -> None:
402
+        """Fail on bad or unsupported master keys file contents.
403
+
404
+        These include unknown versions, and data of the wrong shape.
405
+
406
+        """
366 407
         runner = click.testing.CliRunner(mix_stderr=False)
367 408
         with tests.isolated_vault_exporter_config(
368 409
             monkeypatch=monkeypatch,
... ...
@@ -415,6 +456,20 @@ class TestStoreroom:
415 456
         error_text: str,
416 457
         handler: exporter.ExportVaultConfigDataFunction,
417 458
     ) -> None:
459
+        """Fail on bad decoded directory structures.
460
+
461
+        If the decoded configuration contains directories whose
462
+        structures are inconsistent, it detects this and fails:
463
+
464
+          - The key indicates a directory, but the contents don't.
465
+          - The directory indicates children with invalid path names.
466
+          - The directory indicates children that are missing from the
467
+            configuration entirely.
468
+          - The configuration contains nested subdirectories, but the
469
+            higher-level directories don't indicate their
470
+            subdirectories.
471
+
472
+        """
418 473
         runner = click.testing.CliRunner(mix_stderr=False)
419 474
         # TODO(the-13th-letter): Rewrite using parenthesized
420 475
         # with-statements.
... ...
@@ -432,6 +487,15 @@ class TestStoreroom:
432 487
             handler(format='storeroom')
433 488
 
434 489
     def test_404_decrypt_keys_wrong_data_length(self) -> None:
490
+        """Fail on internal structural data of the wrong size.
491
+
492
+        Specifically, fail on internal structural data such as master
493
+        keys or session keys that is correctly encrypted according to
494
+        its MAC, but is of the wrong shape.  (Since the data usually are
495
+        keys and thus are opaque, the only detectable shape violation is
496
+        the wrong size of the data.)
497
+
498
+        """
435 499
         payload = (
436 500
             b"Any text here, as long as it isn't exactly 64 or 96 bytes long."
437 501
         )
... ...
@@ -480,6 +544,7 @@ class TestStoreroom:
480 544
         ),
481 545
     )
482 546
     def test_405_decrypt_keys_invalid_signature(self, data: bytes) -> None:
547
+        """Fail on bad MAC values."""
483 548
         key = b'DEADBEEFdeadbeefDeAdBeEfdEaDbEeF'
484 549
         # Guessing a correct payload plus MAC would be a pre-image
485 550
         # attack on the underlying hash function (SHA-256), i.e. is
... ...
@@ -500,6 +565,8 @@ class TestStoreroom:
500 565
 
501 566
 
502 567
 class TestVaultNativeConfig:
568
+    """Test the vault-native handler and handler machinery."""
569
+
503 570
     @pytest.mark.parametrize(
504 571
         ['iterations', 'result'],
505 572
         [
... ...
@@ -508,6 +575,7 @@ class TestVaultNativeConfig:
508 575
         ],
509 576
     )
510 577
     def test_200_pbkdf2_manually(self, iterations: int, result: bytes) -> None:
578
+        """The PBKDF2 helper function works."""
511 579
         assert (
512 580
             vault_native.VaultNativeConfigParser._pbkdf2(
513 581
                 tests.VAULT_MASTER_KEY.encode('utf-8'), 32, iterations
... ...
@@ -551,7 +619,7 @@ class TestVaultNativeConfig:
551 619
             pytest.param(exporter.export_vault_config_data, id='dispatcher'),
552 620
         ],
553 621
     )
554
-    def test_201_export_vault_native_data_no_arguments(
622
+    def test_201_export_vault_native_data_explicit_version(
555 623
         self,
556 624
         monkeypatch: pytest.MonkeyPatch,
557 625
         config: str,
... ...
@@ -559,6 +627,16 @@ class TestVaultNativeConfig:
559 627
         result: _types.VaultConfig | type[Exception],
560 628
         handler: exporter.ExportVaultConfigDataFunction,
561 629
     ) -> None:
630
+        """Accept data only of the correct version.
631
+
632
+        Note: Historic behavior
633
+            `derivepassphrase` versions prior to 0.5 automatically tried
634
+            to parse vault-native configurations as v0.3-type, then
635
+            v0.2-type.  Since `derivepassphrase` 0.5, the command-line
636
+            interface still tries multi-version parsing, but the API
637
+            no longer does.
638
+
639
+        """
562 640
         runner = click.testing.CliRunner(mix_stderr=False)
563 641
         with tests.isolated_vault_exporter_config(
564 642
             monkeypatch=monkeypatch,
... ...
@@ -604,6 +682,12 @@ class TestVaultNativeConfig:
604 682
         key: str | Buffer | None,
605 683
         handler: exporter.ExportVaultConfigDataFunction,
606 684
     ) -> None:
685
+        """The handler supports different argument types.
686
+
687
+        The [`exporter.export_vault_config_data`][] dispatcher supports
688
+        them as well.
689
+
690
+        """
607 691
         runner = click.testing.CliRunner(mix_stderr=False)
608 692
         with tests.isolated_vault_exporter_config(
609 693
             monkeypatch=monkeypatch,
... ...
@@ -640,6 +724,8 @@ class TestVaultNativeConfig:
640 724
         config: str,
641 725
         result: dict[str, Any],
642 726
     ) -> None:
727
+        """Cache the results of decrypting/decoding a configuration."""
728
+
643 729
         def null_func(name: str) -> Callable[..., None]:
644 730
             def func(*_args: Any, **_kwargs: Any) -> None:  # pragma: no cover
645 731
                 msg = f'disallowed and stubbed out function {name} called'
... ...
@@ -675,5 +761,6 @@ class TestVaultNativeConfig:
675 761
             assert super_call(parser) == result
676 762
 
677 763
     def test_400_no_password(self) -> None:
764
+        """Fail on empty master keys/master passphrases."""
678 765
         with pytest.raises(ValueError, match='Password must not be empty'):
679 766
             vault_native.VaultNativeV03ConfigParser(b'', b'')
... ...
@@ -19,6 +19,8 @@ if TYPE_CHECKING:
19 19
 
20 20
 
21 21
 class Test001ExporterUtils:
22
+    """Test the utility functions in the `exporter` subpackage."""
23
+
22 24
     @pytest.mark.parametrize(
23 25
         ['expected', 'vault_key', 'logname', 'user', 'username'],
24 26
         [
... ...
@@ -48,6 +50,12 @@ class Test001ExporterUtils:
48 50
         user: str | None,
49 51
         username: str | None,
50 52
     ) -> None:
53
+        """Look up the vault key in `VAULT_KEY`/`LOGNAME`/`USER`/`USERNAME`.
54
+
55
+        The correct environment variable value is used, according to
56
+        their relative priorities.
57
+
58
+        """
51 59
         priority_list = [
52 60
             ('VAULT_KEY', vault_key),
53 61
             ('LOGNAME', logname),
... ...
@@ -77,6 +85,11 @@ class Test001ExporterUtils:
77 85
         expected: pathlib.Path,
78 86
         path: str | os.PathLike[str] | None,
79 87
     ) -> None:
88
+        """Determine the vault path from `VAULT_PATH`.
89
+
90
+        Handle relative paths, absolute paths, and missing paths.
91
+
92
+        """
80 93
         runner = click.testing.CliRunner(mix_stderr=False)
81 94
         with tests.isolated_vault_exporter_config(
82 95
             monkeypatch=monkeypatch, runner=runner
... ...
@@ -93,6 +106,8 @@ class Test001ExporterUtils:
93 106
     def test_220_register_export_vault_config_data_handler(
94 107
         self, monkeypatch: pytest.MonkeyPatch
95 108
     ) -> None:
109
+        """Register vault config data export handlers."""
110
+
96 111
         def handler(  # pragma: no cover
97 112
             path: str | bytes | os.PathLike | None = None,
98 113
             key: str | Buffer | None = None,
... ...
@@ -120,6 +135,7 @@ class Test001ExporterUtils:
120 135
     def test_300_get_vault_key_without_envs(
121 136
         self, monkeypatch: pytest.MonkeyPatch
122 137
     ) -> None:
138
+        """Fail to look up the vault key in the empty environment."""
123 139
         monkeypatch.delenv('VAULT_KEY', raising=False)
124 140
         monkeypatch.delenv('LOGNAME', raising=False)
125 141
         monkeypatch.delenv('USER', raising=False)
... ...
@@ -130,6 +146,8 @@ class Test001ExporterUtils:
130 146
     def test_310_get_vault_path_without_home(
131 147
         self, monkeypatch: pytest.MonkeyPatch
132 148
     ) -> None:
149
+        """Fail to look up the vault path without `HOME`."""
150
+
133 151
         def raiser(*_args: Any, **_kwargs: Any) -> Any:
134 152
             raise RuntimeError('Cannot determine home directory.')  # noqa: EM101,TRY003
135 153
 
... ...
@@ -162,6 +180,13 @@ class Test001ExporterUtils:
162 180
         namelist: tuple[str, ...],
163 181
         err_pat: str,
164 182
     ) -> None:
183
+        """Fail to register a vault config data export handler.
184
+
185
+        Fail because e.g. the associated name is missing, or already
186
+        present in the handler registry.
187
+
188
+        """
189
+
165 190
         def handler(  # pragma: no cover
166 191
             path: str | bytes | os.PathLike | None = None,
167 192
             key: str | Buffer | None = None,
... ...
@@ -183,6 +208,7 @@ class Test001ExporterUtils:
183 208
     def test_321_export_vault_config_data_bad_handler(
184 209
         self, monkeypatch: pytest.MonkeyPatch
185 210
     ) -> None:
211
+        """Fail to export vault config data without known handlers."""
186 212
         monkeypatch.setattr(exporter, '_export_vault_config_data_registry', {})
187 213
         monkeypatch.setattr(
188 214
             exporter, 'find_vault_config_data_handlers', lambda: None
... ...
@@ -195,10 +221,13 @@ class Test001ExporterUtils:
195 221
 
196 222
 
197 223
 class Test002CLI:
224
+    """Test the command-line functionality of the `exporter` subpackage."""
225
+
198 226
     def test_300_invalid_format(
199 227
         self,
200 228
         monkeypatch: pytest.MonkeyPatch,
201 229
     ) -> None:
230
+        """Reject invalid vault configuration format names."""
202 231
         runner = click.testing.CliRunner(mix_stderr=False)
203 232
         with tests.isolated_vault_exporter_config(
204 233
             monkeypatch=monkeypatch,
... ...
@@ -249,6 +278,7 @@ class Test002CLI:
249 278
         config: str | bytes,
250 279
         key: str,
251 280
     ) -> None:
281
+        """Abort export call if no cryptography is available."""
252 282
         runner = click.testing.CliRunner(mix_stderr=False)
253 283
         with tests.isolated_vault_exporter_config(
254 284
             monkeypatch=monkeypatch,
... ...
@@ -19,6 +19,8 @@ def bitseq(string: str) -> list[int]:
19 19
 
20 20
 
21 21
 class TestStaticFunctionality:
22
+    """Test the static functionality in the `sequin` module."""
23
+
22 24
     @pytest.mark.parametrize(
23 25
         ['sequence', 'base', 'expected'],
24 26
         [
... ...
@@ -32,6 +34,11 @@ class TestStaticFunctionality:
32 34
     def test_200_big_endian_number(
33 35
         self, sequence: list[int], base: int, expected: int
34 36
     ) -> None:
37
+        """Conversion to big endian numbers in any base works.
38
+
39
+        See [`sequin.Sequin.generate`][] for where this is used.
40
+
41
+        """
35 42
         assert (
36 43
             sequin.Sequin._big_endian_number(sequence, base=base)
37 44
         ) == expected
... ...
@@ -51,11 +58,18 @@ class TestStaticFunctionality:
51 58
         sequence: list[int],
52 59
         base: int,
53 60
     ) -> None:
61
+        """Nonsensical conversion of numbers in a given base raises.
62
+
63
+        See [`sequin.Sequin.generate`][] for where this is used.
64
+
65
+        """
54 66
         with pytest.raises(exc_type, match=exc_pattern):
55 67
             sequin.Sequin._big_endian_number(sequence, base=base)
56 68
 
57 69
 
58 70
 class TestSequin:
71
+    """Test the `Sequin` class."""
72
+
59 73
     @pytest.mark.parametrize(
60 74
         ['sequence', 'is_bitstring', 'expected'],
61 75
         [
... ...
@@ -75,10 +89,12 @@ class TestSequin:
75 89
         is_bitstring: bool,
76 90
         expected: list[int],
77 91
     ) -> None:
92
+        """The constructor handles both bit and integer sequences."""
78 93
         seq = sequin.Sequin(sequence, is_bitstring=is_bitstring)
79 94
         assert seq.bases == {2: collections.deque(expected)}
80 95
 
81 96
     def test_201_generating(self) -> None:
97
+        """The sequin generates deterministic sequences."""
82 98
         seq = sequin.Sequin(
83 99
             [1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1], is_bitstring=True
84 100
         )
... ...
@@ -97,6 +113,7 @@ class TestSequin:
97 113
             seq.generate(0)
98 114
 
99 115
     def test_210_internal_generating(self) -> None:
116
+        """The sequin internals generate deterministic sequences."""
100 117
         seq = sequin.Sequin(
101 118
             [1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1], is_bitstring=True
102 119
         )
... ...
@@ -115,6 +132,12 @@ class TestSequin:
115 132
             seq._generate_inner(16, base=1)
116 133
 
117 134
     def test_211_shifting(self) -> None:
135
+        """The sequin manages the pool of remaining entropy for each base.
136
+
137
+        Specifically, the sequin implements all-or-nothing fixed-length
138
+        draws from the entropy pool.
139
+
140
+        """
118 141
         seq = sequin.Sequin([1, 0, 1, 0, 0, 1, 0, 0, 0, 1], is_bitstring=True)
119 142
         assert seq.bases == {
120 143
             2: collections.deque([1, 0, 1, 0, 0, 1, 0, 0, 0, 1])
... ...
@@ -149,5 +172,6 @@ class TestSequin:
149 172
         exc_type: type[Exception],
150 173
         exc_pattern: str,
151 174
     ) -> None:
175
+        """The sequin raises on invalid bit and integer sequences."""
152 176
         with pytest.raises(exc_type, match=exc_pattern):
153 177
             sequin.Sequin(sequence, is_bitstring=is_bitstring)
... ...
@@ -28,6 +28,10 @@ if TYPE_CHECKING:
28 28
 
29 29
 
30 30
 class TestStaticFunctionality:
31
+    """Test the static functionality of the `ssh_agent` module."""
32
+
33
+    # TODO(the-13th-letter): Re-evaluate if this check is worth keeping.
34
+    # It cannot provide true tamper-resistence, but probably appears to.
31 35
     @pytest.mark.parametrize(
32 36
         ['public_key', 'public_key_data'],
33 37
         [
... ...
@@ -39,6 +43,7 @@ class TestStaticFunctionality:
39 43
     def test_100_key_decoding(
40 44
         self, public_key: bytes, public_key_data: bytes
41 45
     ) -> None:
46
+        """The [`tests.ALL_KEYS`][] public key data looks sane."""
42 47
         keydata = base64.b64decode(public_key.split(None, 2)[1])
43 48
         assert keydata == public_key_data, (
44 49
             "recorded public key data doesn't match"
... ...
@@ -118,6 +123,7 @@ class TestStaticFunctionality:
118 123
     def test_190_sh_export_line_parsing(
119 124
         self, line: str, env_name: str, value: str | None
120 125
     ) -> None:
126
+        """[`tests.parse_sh_export_line`][] works."""
121 127
         if value is not None:
122 128
             assert tests.parse_sh_export_line(line, env_name=env_name) == value
123 129
         else:
... ...
@@ -129,6 +135,7 @@ class TestStaticFunctionality:
129 135
         monkeypatch: pytest.MonkeyPatch,
130 136
         skip_if_no_af_unix_support: None,
131 137
     ) -> None:
138
+        """Abort if the running agent cannot be located."""
132 139
         del skip_if_no_af_unix_support
133 140
         monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
134 141
         with pytest.raises(
... ...
@@ -143,6 +150,7 @@ class TestStaticFunctionality:
143 150
         ],
144 151
     )
145 152
     def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None:
153
+        """`uint32` encoding works."""
146 154
         uint32 = ssh_agent.SSHAgentClient.uint32
147 155
         assert uint32(input) == expected
148 156
 
... ...
@@ -169,6 +177,7 @@ class TestStaticFunctionality:
169 177
     def test_211_string(
170 178
         self, input: bytes | bytearray, expected: bytes | bytearray
171 179
     ) -> None:
180
+        """SSH string encoding works."""
172 181
         string = ssh_agent.SSHAgentClient.string
173 182
         assert bytes(string(input)) == expected
174 183
 
... ...
@@ -190,6 +199,7 @@ class TestStaticFunctionality:
190 199
     def test_212_unstring(
191 200
         self, input: bytes | bytearray, expected: bytes | bytearray
192 201
     ) -> None:
202
+        """SSH string decoding works."""
193 203
         unstring = ssh_agent.SSHAgentClient.unstring
194 204
         unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
195 205
         assert bytes(unstring(input)) == expected
... ...
@@ -218,6 +228,7 @@ class TestStaticFunctionality:
218 228
     def test_310_uint32_exceptions(
219 229
         self, value: int, exc_type: type[Exception], exc_pattern: str
220 230
     ) -> None:
231
+        """`uint32` encoding fails for out-of-bound values."""
221 232
         uint32 = ssh_agent.SSHAgentClient.uint32
222 233
         with pytest.raises(exc_type, match=exc_pattern):
223 234
             uint32(value)
... ...
@@ -233,6 +244,7 @@ class TestStaticFunctionality:
233 244
     def test_311_string_exceptions(
234 245
         self, input: Any, exc_type: type[Exception], exc_pattern: str
235 246
     ) -> None:
247
+        """SSH string encoding fails for non-strings."""
236 248
         string = ssh_agent.SSHAgentClient.string
237 249
         with pytest.raises(exc_type, match=exc_pattern):
238 250
             string(input)
... ...
@@ -274,6 +286,7 @@ class TestStaticFunctionality:
274 286
         has_trailer: bool,
275 287
         parts: tuple[bytes | bytearray, bytes | bytearray] | None,
276 288
     ) -> None:
289
+        """SSH string decoding fails for invalid values."""
277 290
         unstring = ssh_agent.SSHAgentClient.unstring
278 291
         unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
279 292
         with pytest.raises(exc_type, match=exc_pattern):
... ...
@@ -286,6 +299,11 @@ class TestStaticFunctionality:
286 299
 
287 300
 
288 301
 class TestAgentInteraction:
302
+    """Test actually talking to the SSH agent."""
303
+
304
+    # TODO(the-13th-letter): Convert skip into xfail, and include the
305
+    # key type in the skip/xfail message.  This means the key type needs
306
+    # to be passed to the test function as well.
289 307
     @pytest.mark.parametrize(
290 308
         'ssh_test_key',
291 309
         list(tests.SUPPORTED_KEYS.values()),
... ...
@@ -296,6 +314,13 @@ class TestAgentInteraction:
296 314
         ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
297 315
         ssh_test_key: tests.SSHTestKey,
298 316
     ) -> None:
317
+        """Signing data with specific SSH keys works.
318
+
319
+        Single tests may abort early (skip) if the indicated key is not
320
+        loaded in the agent.  Presumably this means the key type is
321
+        unsupported.
322
+
323
+        """
299 324
         client = ssh_agent_client_with_test_keys_loaded
300 325
         key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
301 326
         public_key_data = ssh_test_key.public_key_data
... ...
@@ -318,6 +343,9 @@ class TestAgentInteraction:
318 343
             == derived_passphrase
319 344
         ), 'SSH signature mismatch'
320 345
 
346
+    # TODO(the-13th-letter): Include the key type in the skip message.
347
+    # This means the key type needs to be passed to the test function as
348
+    # well.
321 349
     @pytest.mark.parametrize(
322 350
         'ssh_test_key',
323 351
         list(tests.UNSUITABLE_KEYS.values()),
... ...
@@ -328,6 +356,15 @@ class TestAgentInteraction:
328 356
         ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
329 357
         ssh_test_key: tests.SSHTestKey,
330 358
     ) -> None:
359
+        """Using an unsuitable key with [`vault.Vault`][] fails.
360
+
361
+        Single tests may abort early (skip) if the indicated key is not
362
+        loaded in the agent.  Presumably this means the key type is
363
+        unsupported.  Single tests may also abort early if the agent
364
+        ensures that the generally unsuitable key is actually suitable
365
+        under this agent.
366
+
367
+        """
331 368
         client = ssh_agent_client_with_test_keys_loaded
332 369
         key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
333 370
         public_key_data = ssh_test_key.public_key_data
... ...
@@ -357,9 +394,16 @@ class TestAgentInteraction:
357 394
         key: bytes,
358 395
         single: bool,
359 396
     ) -> None:
397
+        """The key selector presents exactly the suitable keys.
398
+
399
+        "Suitable" here means suitability for this SSH agent
400
+        specifically.
401
+
402
+        """
360 403
         client = ssh_agent_client_with_test_keys_loaded
361 404
 
362 405
         def key_is_suitable(key: bytes) -> bool:
406
+            """Stub out [`vault.Vault.key_is_suitable`][]."""
363 407
             always = {v.public_key_data for v in tests.SUPPORTED_KEYS.values()}
364 408
             dsa = {
365 409
                 v.public_key_data
... ...
@@ -370,6 +414,11 @@ class TestAgentInteraction:
370 414
                 client.has_deterministic_dsa_signatures() and key in dsa
371 415
             )
372 416
 
417
+        # TODO(the-13th-letter): Handle the unlikely(?) case that only
418
+        # one test key is loaded, but `single` is False.  Rename the
419
+        # `index` variable to `input`, store the `input` in there, and
420
+        # make the definition of `text` in the else block dependent on
421
+        # `n` being singular or non-singular.
373 422
         if single:
374 423
             monkeypatch.setattr(
375 424
                 ssh_agent.SSHAgentClient,
... ...
@@ -399,9 +448,12 @@ class TestAgentInteraction:
399 448
 
400 449
         @click.command()
401 450
         def driver() -> None:
451
+            """Call `cli._select_ssh_key` directly, as a command."""
402 452
             key = cli._select_ssh_key()
403 453
             click.echo(base64.standard_b64encode(key).decode('ASCII'))
404 454
 
455
+        # TODO(the-13th-letter): (Continued from above.)  Update input
456
+        # data to use `index`/`input` directly and unconditionally.
405 457
         runner = click.testing.CliRunner(mix_stderr=True)
406 458
         result_ = runner.invoke(
407 459
             driver,
... ...
@@ -418,6 +470,7 @@ class TestAgentInteraction:
418 470
         monkeypatch: pytest.MonkeyPatch,
419 471
         running_ssh_agent: tests.RunningSSHAgentInfo,
420 472
     ) -> None:
473
+        """Fail if the agent address is invalid."""
421 474
         with monkeypatch.context() as monkeypatch2:
422 475
             monkeypatch2.setenv(
423 476
                 'SSH_AUTH_SOCK', running_ssh_agent.socket + '~'
... ...
@@ -430,6 +483,7 @@ class TestAgentInteraction:
430 483
         self,
431 484
         monkeypatch: pytest.MonkeyPatch,
432 485
     ) -> None:
486
+        """Fail without [`socket.AF_UNIX`][] support."""
433 487
         with monkeypatch.context() as monkeypatch2:
434 488
             monkeypatch2.setenv('SSH_AUTH_SOCK', "the value doesn't matter")
435 489
             monkeypatch2.delattr(socket, 'AF_UNIX', raising=False)
... ...
@@ -453,6 +507,7 @@ class TestAgentInteraction:
453 507
         running_ssh_agent: tests.RunningSSHAgentInfo,
454 508
         response: bytes,
455 509
     ) -> None:
510
+        """Fail on truncated responses from the SSH agent."""
456 511
         del running_ssh_agent
457 512
         client = ssh_agent.SSHAgentClient()
458 513
         response_stream = io.BytesIO(response)
... ...
@@ -504,6 +559,16 @@ class TestAgentInteraction:
504 559
         exc_type: type[Exception],
505 560
         exc_pattern: str,
506 561
     ) -> None:
562
+        """Fail on problems during key listing.
563
+
564
+        Known problems:
565
+
566
+          - The agent refuses, or otherwise indicates the operation
567
+            failed.
568
+          - The agent response is truncated.
569
+          - The agent response is overlong.
570
+
571
+        """
507 572
         del running_ssh_agent
508 573
 
509 574
         passed_response_code = response_code
... ...
@@ -584,6 +649,15 @@ class TestAgentInteraction:
584 649
         exc_type: type[Exception],
585 650
         exc_pattern: str,
586 651
     ) -> None:
652
+        """Fail on problems during signing.
653
+
654
+        Known problems:
655
+
656
+          - The key is not loaded into the agent.
657
+          - The agent refuses, or otherwise indicates the operation
658
+            failed.
659
+
660
+        """
587 661
         del running_ssh_agent
588 662
         passed_response_code = response_code
589 663
 
... ...
@@ -651,6 +725,15 @@ class TestAgentInteraction:
651 725
         exc_type: type[Exception],
652 726
         exc_pattern: str,
653 727
     ) -> None:
728
+        """Fail on problems during signing.
729
+
730
+        Known problems:
731
+
732
+          - The key is not loaded into the agent.
733
+          - The agent refuses, or otherwise indicates the operation
734
+            failed.
735
+
736
+        """
654 737
         del running_ssh_agent
655 738
 
656 739
         # TODO(the-13th-letter): Rewrite using parenthesized
... ...
@@ -683,6 +766,7 @@ class TestAgentInteraction:
683 766
         running_ssh_agent: tests.RunningSSHAgentInfo,
684 767
         response_data: bytes,
685 768
     ) -> None:
769
+        """Fail on malformed responses while querying extensions."""
686 770
         del running_ssh_agent
687 771
 
688 772
         def request(
... ...
@@ -738,17 +822,21 @@ class TestAgentInteraction:
738 822
 
739 823
 
740 824
 class TestHypotheses:
825
+    """Test properties via hypothesis."""
826
+
741 827
     @hypothesis.given(strategies.integers(min_value=0, max_value=0xFFFFFFFF))
742 828
     # standard example value
743 829
     @hypothesis.example(0xDEADBEEF)
744
-    def test_210_uint32(self, num: int) -> None:
830
+    def test_210a_uint32_from_number(self, num: int) -> None:
831
+        """`uint32` encoding works, starting from numbers."""
745 832
         uint32 = ssh_agent.SSHAgentClient.uint32
746 833
         assert int.from_bytes(uint32(num), 'big', signed=False) == num
747 834
 
748 835
     @hypothesis.given(strategies.binary(min_size=4, max_size=4))
749 836
     # standard example value
750 837
     @hypothesis.example(b'\xde\xad\xbe\xef')
751
-    def test_210a_uint32(self, bytestring: bytes) -> None:
838
+    def test_210b_uint32_from_bytestring(self, bytestring: bytes) -> None:
839
+        """`uint32` encoding works, starting from length four byte strings."""
752 840
         uint32 = ssh_agent.SSHAgentClient.uint32
753 841
         assert (
754 842
             uint32(int.from_bytes(bytestring, 'big', signed=False))
... ...
@@ -758,7 +846,8 @@ class TestHypotheses:
758 846
     @hypothesis.given(strategies.binary(max_size=0x0001FFFF))
759 847
     # example: highest order bit is set
760 848
     @hypothesis.example(b'DEADBEEF' * 10000)
761
-    def test_211_string(self, bytestring: bytes) -> None:
849
+    def test_211a_string_from_bytestring(self, bytestring: bytes) -> None:
850
+        """SSH string encoding works, starting from a byte string."""
762 851
         res = ssh_agent.SSHAgentClient.string(bytestring)
763 852
         assert res.startswith((b'\x00\x00', b'\x00\x01'))
764 853
         assert int.from_bytes(res[:4], 'big', signed=False) == len(bytestring)
... ...
@@ -768,6 +857,7 @@ class TestHypotheses:
768 857
     # example: check for double-deserialization
769 858
     @hypothesis.example(b'\x00\x00\x00\x07ssh-rsa')
770 859
     def test_212_string_unstring(self, bytestring: bytes) -> None:
860
+        """SSH string decoding of encoded SSH strings works."""
771 861
         string = ssh_agent.SSHAgentClient.string
772 862
         unstring = ssh_agent.SSHAgentClient.unstring
773 863
         unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
... ...
@@ -58,6 +58,11 @@ from derivepassphrase import _types
58 58
     ),
59 59
 )
60 60
 def test_100_js_truthiness(value: Any) -> None:
61
+    """Determine the truthiness of a value according to JavaScript.
62
+
63
+    Use hypothesis to generate test values.
64
+
65
+    """
61 66
     expected = (
62 67
         value is not None  # noqa: PLR1714
63 68
         and value != False  # noqa: E712
... ...
@@ -78,6 +83,15 @@ def test_100_js_truthiness(value: Any) -> None:
78 83
     ids=tests._test_config_ids,
79 84
 )
80 85
 def test_200_is_vault_config(test_config: tests.VaultTestConfig) -> None:
86
+    """Is this vault configuration recognized as valid/invalid?
87
+
88
+    Check all test configurations that do not need custom validation
89
+    settings.
90
+
91
+    This primarily tests the [`_types.is_vault_config`][] and
92
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
93
+
94
+    """
81 95
     obj, comment, _ = test_config
82 96
     obj = copy.deepcopy(obj)
83 97
     _types.clean_up_falsy_vault_config_values(obj)
... ...
@@ -99,6 +113,15 @@ def test_200_is_vault_config(test_config: tests.VaultTestConfig) -> None:
99 113
 def test_200a_is_vault_config_smudged(
100 114
     test_config: tests.VaultTestConfig,
101 115
 ) -> None:
116
+    """Is this vault configuration recognized as valid/invalid?
117
+
118
+    Generate test data via hypothesis by smudging all valid test
119
+    configurations.
120
+
121
+    This primarily tests the [`_types.is_vault_config`][] and
122
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
123
+
124
+    """
102 125
     obj_, comment, _ = test_config
103 126
     obj = copy.deepcopy(obj_)
104 127
     did_cleanup = _types.clean_up_falsy_vault_config_values(obj)
... ...
@@ -116,6 +139,15 @@ def test_200a_is_vault_config_smudged(
116 139
     'test_config', tests.TEST_CONFIGS, ids=tests._test_config_ids
117 140
 )
118 141
 def test_400_validate_vault_config(test_config: tests.VaultTestConfig) -> None:
142
+    """Validate this vault configuration.
143
+
144
+    Check all test configurations, including those with non-standard
145
+    validation settings.
146
+
147
+    This primarily tests the [`_types.validate_vault_config`][] and
148
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
149
+
150
+    """
119 151
     obj, comment, validation_settings = test_config
120 152
     (allow_unknown_settings,) = validation_settings or (True,)
121 153
     obj = copy.deepcopy(obj)
... ...
@@ -147,6 +179,15 @@ def test_400_validate_vault_config(test_config: tests.VaultTestConfig) -> None:
147 179
 def test_400a_validate_vault_config_smudged(
148 180
     test_config: tests.VaultTestConfig,
149 181
 ) -> None:
182
+    """Validate this vault configuration.
183
+
184
+    Generate test data via hypothesis by smudging all smudgable test
185
+    configurations.
186
+
187
+    This primarily tests the [`_types.validate_vault_config`][] and
188
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
189
+
190
+    """
150 191
     obj_, comment, validation_settings = test_config
151 192
     (allow_unknown_settings,) = validation_settings or (True,)
152 193
     obj = copy.deepcopy(obj_)
... ...
@@ -24,9 +24,20 @@ Vault: TypeAlias = derivepassphrase.vault.Vault
24 24
 
25 25
 
26 26
 class TestVault:
27
+    """Test passphrase derivation with the "vault" scheme."""
28
+
27 29
     phrase = b'She cells C shells bye the sea shoars'
30
+    """The standard passphrase from <i>vault</i>(1)'s test suite."""
28 31
     google_phrase = rb': 4TVH#5:aZl8LueOT\{'
32
+    """
33
+    The standard derived passphrase for the "google" service, from
34
+    <i>vault</i>(1)'s test suite.
35
+    """
29 36
     twitter_phrase = rb"[ (HN_N:lI&<ro=)3'g9"
37
+    """
38
+    The standard derived passphrase for the "twitter" service, from
39
+    <i>vault</i>(1)'s test suite.
40
+    """
30 41
 
31 42
     @pytest.mark.parametrize(
32 43
         ['service', 'expected'],
... ...
@@ -38,30 +49,36 @@ class TestVault:
38 49
     def test_200_basic_configuration(
39 50
         self, service: bytes | str, expected: bytes
40 51
     ) -> None:
52
+        """Deriving a passphrase principally works."""
41 53
         assert Vault(phrase=self.phrase).generate(service) == expected
42 54
 
43 55
     def test_201_phrase_dependence(self) -> None:
56
+        """The derived passphrase is dependent on the master passphrase."""
44 57
         assert (
45 58
             Vault(phrase=(self.phrase + b'X')).generate('google')
46 59
             == b'n+oIz6sL>K*lTEWYRO%7'
47 60
         )
48 61
 
49 62
     def test_202_reproducibility_and_bytes_service_name(self) -> None:
63
+        """Deriving a passphrase works equally for byte strings."""
50 64
         assert Vault(phrase=self.phrase).generate(b'google') == Vault(
51 65
             phrase=self.phrase
52 66
         ).generate('google')
53 67
 
54 68
     def test_203_reproducibility_and_bytearray_service_name(self) -> None:
69
+        """Deriving a passphrase works equally for byte arrays."""
55 70
         assert Vault(phrase=self.phrase).generate(b'google') == Vault(
56 71
             phrase=self.phrase
57 72
         ).generate(bytearray(b'google'))
58 73
 
59 74
     def test_210_nonstandard_length(self) -> None:
75
+        """Deriving a passphrase adheres to imposed length limits."""
60 76
         assert (
61 77
             Vault(phrase=self.phrase, length=4).generate('google') == b'xDFu'
62 78
         )
63 79
 
64 80
     def test_211_repetition_limit(self) -> None:
81
+        """Deriving a passphrase adheres to imposed repetition limits."""
65 82
         assert (
66 83
             Vault(
67 84
                 phrase=b'', length=24, symbol=0, number=0, repeat=1
... ...
@@ -70,36 +87,44 @@ class TestVault:
70 87
         )
71 88
 
72 89
     def test_212_without_symbols(self) -> None:
90
+        """Deriving a passphrase adheres to imposed limits on symbols."""
73 91
         assert (
74 92
             Vault(phrase=self.phrase, symbol=0).generate('google')
75 93
             == b'XZ4wRe0bZCazbljCaMqR'
76 94
         )
77 95
 
78 96
     def test_213_no_numbers(self) -> None:
97
+        """Deriving a passphrase adheres to imposed limits on numbers."""
79 98
         assert (
80 99
             Vault(phrase=self.phrase, number=0).generate('google')
81 100
             == b'_*$TVH.%^aZl(LUeOT?>'
82 101
         )
83 102
 
84 103
     def test_214_no_lowercase_letters(self) -> None:
104
+        """
105
+        Deriving a passphrase adheres to imposed limits on lowercase letters.
106
+        """
85 107
         assert (
86 108
             Vault(phrase=self.phrase, lower=0).generate('google')
87 109
             == b':{?)+7~@OA:L]!0E$)(+'
88 110
         )
89 111
 
90 112
     def test_215_at_least_5_digits(self) -> None:
113
+        """Deriving a passphrase adheres to imposed counts of numbers."""
91 114
         assert (
92 115
             Vault(phrase=self.phrase, length=8, number=5).generate('songkick')
93 116
             == b'i0908.7['
94 117
         )
95 118
 
96 119
     def test_216_lots_of_spaces(self) -> None:
120
+        """Deriving a passphrase adheres to imposed counts of spaces."""
97 121
         assert (
98 122
             Vault(phrase=self.phrase, space=12).generate('songkick')
99 123
             == b' c   6 Bq  % 5fR    '
100 124
         )
101 125
 
102 126
     def test_217_all_character_classes(self) -> None:
127
+        """Deriving a passphrase adheres to imposed counts of all types."""
103 128
         assert (
104 129
             Vault(
105 130
                 phrase=self.phrase,
... ...
@@ -114,6 +139,11 @@ class TestVault:
114 139
         )
115 140
 
116 141
     def test_218_only_numbers_and_very_high_repetition_limit(self) -> None:
142
+        """Deriving a passphrase adheres to imposed repetition limits.
143
+
144
+        This example is checked explicitly against forbidden substrings.
145
+
146
+        """
117 147
         generated = Vault(
118 148
             phrase=b'',
119 149
             length=40,
... ...
@@ -140,12 +170,14 @@ class TestVault:
140 170
             assert substring not in generated
141 171
 
142 172
     def test_219_very_limited_character_set(self) -> None:
173
+        """Deriving a passphrase works even with limited character sets."""
143 174
         generated = Vault(
144 175
             phrase=b'', length=24, lower=0, upper=0, space=0, symbol=0
145 176
         ).generate('testing')
146 177
         assert generated == b'763252593304946694588866'
147 178
 
148 179
     def test_220_character_set_subtraction(self) -> None:
180
+        """Removing allowed characters internally works."""
149 181
         assert Vault._subtract(b'be', b'abcdef') == bytearray(b'acdf')
150 182
 
151 183
     @pytest.mark.parametrize(
... ...
@@ -170,6 +202,7 @@ class TestVault:
170 202
     def test_221_entropy(
171 203
         self, length: int, settings: dict[str, int], entropy: int
172 204
     ) -> None:
205
+        """Estimating the entropy and sufficient hash length works."""
173 206
         v = Vault(length=length, **settings)  # type: ignore[arg-type]
174 207
         assert math.isclose(v._entropy(), entropy)
175 208
         assert v._estimate_sufficient_hash_length() > 0
... ...
@@ -180,6 +213,9 @@ class TestVault:
180 213
         assert v._estimate_sufficient_hash_length(8.0) >= entropy
181 214
 
182 215
     def test_222_hash_length_estimation(self) -> None:
216
+        """
217
+        Estimating the entropy and hash length for degenerate cases works.
218
+        """
183 219
         v = Vault(
184 220
             phrase=self.phrase,
185 221
             lower=0,
... ...
@@ -205,6 +241,9 @@ class TestVault:
205 241
         service: str | bytes,
206 242
         expected: bytes,
207 243
     ) -> None:
244
+        """
245
+        Estimating the entropy and hash length for the degenerate case works.
246
+        """
208 247
         v = Vault(phrase=self.phrase)
209 248
         monkeypatch.setattr(
210 249
             v,
... ...
@@ -226,6 +265,7 @@ class TestVault:
226 265
         ],
227 266
     )
228 267
     def test_224_binary_strings(self, s: str | bytes | bytearray) -> None:
268
+        """Byte string conversion is idempotent."""
229 269
         binstr = Vault._get_binary_string
230 270
         if isinstance(s, str):
231 271
             assert binstr(s) == s.encode('UTF-8')
... ...
@@ -235,12 +275,14 @@ class TestVault:
235 275
             assert binstr(binstr(s)) == bytes(s)
236 276
 
237 277
     def test_310_too_many_symbols(self) -> None:
278
+        """Deriving short passphrases with large length constraints fails."""
238 279
         with pytest.raises(
239 280
             ValueError, match='requested passphrase length too short'
240 281
         ):
241 282
             Vault(phrase=self.phrase, symbol=100)
242 283
 
243 284
     def test_311_no_viable_characters(self) -> None:
285
+        """Deriving passphrases without allowed characters fails."""
244 286
         with pytest.raises(ValueError, match='no allowed characters left'):
245 287
             Vault(
246 288
                 phrase=self.phrase,
... ...
@@ -253,12 +295,14 @@ class TestVault:
253 295
             )
254 296
 
255 297
     def test_320_character_set_subtraction_duplicate(self) -> None:
298
+        """Character sets do not contain duplicate characters."""
256 299
         with pytest.raises(ValueError, match='duplicate characters'):
257 300
             Vault._subtract(b'abcdef', b'aabbccddeeff')
258 301
         with pytest.raises(ValueError, match='duplicate characters'):
259 302
             Vault._subtract(b'aabbccddeeff', b'abcdef')
260 303
 
261 304
     def test_322_hash_length_estimation(self) -> None:
305
+        """Hash length estimation rejects invalid safety factors."""
262 306
         v = Vault(phrase=self.phrase)
263 307
         with pytest.raises(ValueError, match='invalid safety factor'):
264 308
             assert v._estimate_sufficient_hash_length(-1.0)
... ...
@@ -269,6 +313,8 @@ class TestVault:
269 313
 
270 314
 
271 315
 class TestHypotheses:
316
+    """Test properties via hypothesis."""
317
+
272 318
     @tests.hypothesis_settings_coverage_compatible
273 319
     @hypothesis.given(
274 320
         phrase=strategies.one_of(
... ...
@@ -328,6 +374,7 @@ class TestHypotheses:
328 374
         config: dict[str, int],
329 375
         service: str,
330 376
     ) -> None:
377
+        """Derived passphrases obey character and occurrence restraints."""
331 378
         try:
332 379
             password = Vault(phrase=phrase, **config).generate(service)
333 380
         except ValueError as exc:
... ...
@@ -386,6 +433,7 @@ class TestHypotheses:
386 433
         length: int,
387 434
         service: str,
388 435
     ) -> None:
436
+        """Derived passphrases have the requested length."""
389 437
         password = Vault(phrase=phrase, length=length).generate(service)
390 438
         assert len(password) == length
391 439
 
... ...
@@ -412,6 +460,7 @@ class TestHypotheses:
412 460
         repeat: int,
413 461
         service: str,
414 462
     ) -> None:
463
+        """Derived passphrases obey the given occurrence constraint."""
415 464
         password = Vault(phrase=phrase, length=length, repeat=repeat).generate(
416 465
             service
417 466
         )
... ...
@@ -42,6 +42,7 @@ all_translatable_strings = [
42 42
 
43 43
 @pytest.fixture(scope='class')
44 44
 def use_debug_translations() -> Iterator[None]:
45
+    """Force the use of debug translations (pytest class fixture)."""
45 46
     with pytest.MonkeyPatch.context() as monkeypatch:
46 47
         monkeypatch.setattr(msg, 'translation', msg.DebugTranslations())
47 48
         yield
... ...
@@ -49,6 +50,7 @@ def use_debug_translations() -> Iterator[None]:
49 50
 
50 51
 @contextlib.contextmanager
51 52
 def monkeypatched_null_translations() -> Iterator[None]:
53
+    """Force the use of no-op translations in this context."""
52 54
     with pytest.MonkeyPatch.context() as monkeypatch:
53 55
         monkeypatch.setattr(msg, 'translation', gettext.NullTranslations())
54 56
         yield
... ...
@@ -56,21 +58,29 @@ def monkeypatched_null_translations() -> Iterator[None]:
56 58
 
57 59
 @pytest.mark.usefixtures('use_debug_translations')
58 60
 class TestL10nMachineryWithDebugTranslations:
61
+    """Test the localization machinery together with debug translations."""
59 62
     error_codes = tuple(
60 63
         sorted(errno.errorcode, key=errno.errorcode.__getitem__)
61 64
     )
65
+    """A cache of the known error codes from the [`errno`][] module."""
62 66
     known_fields_error_messages = tuple(
63 67
         e
64 68
         for e in sorted(msg.ErrMsgTemplate, key=str)
65 69
         if e.value.fields() == ['error', 'filename']
66 70
     )
71
+    """
72
+    A cache of known error messages that contain both `error` and
73
+    `filename` replacement fields.
74
+    """
67 75
     no_fields_messages = tuple(
68 76
         e for e in all_translatable_strings_enum_values if not e.value.fields()
69 77
     )
78
+    """A cache of known messages that don't contain replacement fields."""
70 79
 
71 80
     @hypothesis.given(value=strategies.text(max_size=100))
72 81
     @hypothesis.example('{')
73 82
     def test_100_debug_translation_get_str(self, value: str) -> None:
83
+        """Translating a raw string object does nothing."""
74 84
         translated = msg.translation.gettext(value)
75 85
         assert translated == value
76 86
 
... ...
@@ -79,6 +89,7 @@ class TestL10nMachineryWithDebugTranslations:
79 89
         self,
80 90
         value: msg.TranslatableString,
81 91
     ) -> None:
92
+        """Translating a TranslatableString translates and interpolates."""
82 93
         ts_name = str(all_translatable_strings_dict[value])
83 94
         context = value.l10n_context
84 95
         singular = value.singular
... ...
@@ -94,6 +105,7 @@ class TestL10nMachineryWithDebugTranslations:
94 105
         self,
95 106
         value: msg.MsgTemplate,
96 107
     ) -> None:
108
+        """Translating a MsgTemplate operates on the enum value."""
97 109
         ts_name = str(value)
98 110
         inner_value = cast('msg.TranslatableString', value.value)
99 111
         context = inner_value.l10n_context
... ...
@@ -106,6 +118,7 @@ class TestL10nMachineryWithDebugTranslations:
106 118
     @hypothesis.given(value=strategies.text(max_size=100))
107 119
     @hypothesis.example('{')
108 120
     def test_100c_debug_translation_get_ts_str(self, value: str) -> None:
121
+        """Translating a constant TranslatableString does nothing."""
109 122
         translated = msg.TranslatedString.constant(value)
110 123
         assert str(translated) == value
111 124
 
... ...
@@ -121,6 +134,7 @@ class TestL10nMachineryWithDebugTranslations:
121 134
         self,
122 135
         values: list[msg.MsgTemplate],
123 136
     ) -> None:
137
+        """TranslatableStrings are hashable."""
124 138
         assert len(values) == 2
125 139
         ts0 = msg.TranslatedString(values[0])
126 140
         ts1 = msg.TranslatedString(values[0])
... ...
@@ -148,6 +162,7 @@ class TestL10nMachineryWithDebugTranslations:
148 162
         value: msg.ErrMsgTemplate,
149 163
         errnos: list[int],
150 164
     ) -> None:
165
+        """TranslatableStrings are hashable even with interpolations."""
151 166
         assert len(errnos) == 2
152 167
         error1, error2 = [os.strerror(c) for c in errnos]
153 168
         ts1 = msg.TranslatedString(
... ...
@@ -169,6 +184,7 @@ class TestL10nMachineryWithDebugTranslations:
169 184
         value: msg.ErrMsgTemplate,
170 185
         errno_: int,
171 186
     ) -> None:
187
+        """Interpolated TranslatableStrings with error/filename are hashable."""
172 188
         error = os.strerror(errno_)
173 189
         # The debug translations specifically do *not* differ in output
174 190
         # when the filename is trimmed.  So we need to request some
... ...
@@ -190,6 +206,13 @@ class TestL10nMachineryWithDebugTranslations:
190 206
         self,
191 207
         s: str,
192 208
     ) -> None:
209
+        """TranslatableStrings require fixed replacement fields.
210
+
211
+        They reject attempts at stringification if unknown fields are
212
+        passed, or if fields are missing, or if the format string is
213
+        invalid.
214
+
215
+        """
193 216
         with monkeypatched_null_translations():
194 217
             ts1 = msg.TranslatedString(s)
195 218
             with pytest.raises((KeyError, ValueError)) as excinfo:
... ...
@@ -223,6 +246,7 @@ class TestL10nMachineryWithDebugTranslations:
223 246
         self,
224 247
         s: str,
225 248
     ) -> None:
249
+        """Constant TranslatedStrings don't interpolate fields."""
226 250
         with monkeypatched_null_translations():
227 251
             ts = msg.TranslatedString.constant(s)
228 252
             try:
... ...
@@ -244,6 +268,7 @@ class TestL10nMachineryWithDebugTranslations:
244 268
         self,
245 269
         s: str,
246 270
     ) -> None:
271
+        """Non-format TranslatedStrings don't interpolate fields."""
247 272
         with monkeypatched_null_translations():
248 273
             ts_inner = msg.TranslatableString(
249 274
                 '',
250 275