Refactor the heavy-duty command-line interface tests
Marco Ricci

Marco Ricci commited on 2025-08-31 21:09:13
Zeige 2 geänderte Dateien mit 437 Einfügungen und 198 Löschungen.


(This is part 10 of a series of refactorings for the test suite.)

In the heavy-duty command-line tests, factor out common test setups and
common hypothesis strategies.  In particular, share the hypothesis
strategies and the skeletal procedure for both affected state machines
because they offer more or less the same transition rules.

For the hypothesis test machinery, fix some types and some import
aliases that affect the heavy-duty command-line tests.
... ...
@@ -22,8 +22,7 @@ from typing import TYPE_CHECKING
22 22
 import hypothesis
23 23
 from hypothesis import strategies
24 24
 
25
-import tests.data
26
-import tests.machinery
25
+from tests import data, machinery
27 26
 from derivepassphrase import _types
28 27
 
29 28
 __all__ = ()
... ...
@@ -53,9 +52,7 @@ def get_concurrency_step_count(
53 52
     """
54 53
     if settings is None:  # pragma: no cover
55 54
         settings = hypothesis.settings()
56
-    return min(
57
-        tests.machinery.get_concurrency_limit(), settings.stateful_step_count
58
-    )
55
+    return min(machinery.get_concurrency_limit(), settings.stateful_step_count)
59 56
 
60 57
 
61 58
 # Hypothesis strategies
... ...
@@ -63,7 +60,9 @@ def get_concurrency_step_count(
63 60
 
64 61
 
65 62
 @strategies.composite
66
-def vault_full_service_config(draw: strategies.DrawFn) -> dict[str, int]:
63
+def vault_full_service_config(
64
+    draw: strategies.DrawFn,
65
+) -> _types.VaultConfigServicesSettings:
67 66
     """Hypothesis strategy for full vault service configurations.
68 67
 
69 68
     Returns a sample configuration with restrictions on length, repeat
... ...
@@ -106,10 +105,12 @@ def vault_full_service_config(draw: strategies.DrawFn) -> dict[str, int]:
106 105
 @strategies.composite
107 106
 def smudged_vault_test_config(
108 107
     draw: strategies.DrawFn,
109
-    config: Any = strategies.sampled_from(tests.data.TEST_CONFIGS).filter(  # noqa: B008
110
-        tests.data.VaultTestConfig.is_smudgable
108
+    config: strategies.SearchStrategy[
109
+        data.VaultTestConfig
110
+    ] = strategies.sampled_from(data.TEST_CONFIGS).filter(  # noqa: B008
111
+        data.VaultTestConfig.is_smudgable
111 112
     ),
112
-) -> Any:
113
+) -> data.VaultTestConfig:
113 114
     """Hypothesis strategy to replace falsy values with other falsy values.
114 115
 
115 116
     Uses [`_types.js_truthiness`][] internally, which is tested
... ...
@@ -167,6 +168,4 @@ def smudged_vault_test_config(
167 168
             if not _types.js_truthiness(value) and value != 0:
168 169
                 service[key] = draw(strategies.sampled_from(falsy_no_zero))
169 170
     hypothesis.assume(obj != conf.config)
170
-    return tests.data.VaultTestConfig(
171
-        obj, conf.comment, conf.validation_settings
172
-    )
171
+    return data.VaultTestConfig(obj, conf.comment, conf.validation_settings)
... ...
@@ -26,7 +26,8 @@ from tests.machinery import pytest as pytest_machinery
26 26
 
27 27
 if TYPE_CHECKING:
28 28
     import multiprocessing
29
-    from collections.abc import Iterable
29
+    from collections.abc import Iterable, Sequence
30
+    from collections.abc import Set as AbstractSet
30 31
 
31 32
     from typing_extensions import Literal
32 33
 
... ...
@@ -48,9 +49,26 @@ VALID_PROPERTIES = (
48 49
 """Known vault properties.  Used for the [`ConfigManagementStateMachine`][]."""
49 50
 
50 51
 
52
+class Strategies:
53
+    """Common hypothesis data generation strategies."""
54
+
55
+    @staticmethod
56
+    def assemble_config(
57
+        global_data: _types.VaultConfigGlobalSettings,
58
+        service_data: Sequence[tuple[str, _types.VaultConfigServicesSettings]],
59
+    ) -> _types.VaultConfig:
60
+        """Return a vault config using the global and service data."""
61
+        services_dict = dict(service_data)
62
+        return (
63
+            {"global": global_data, "services": services_dict}
64
+            if global_data
65
+            else {"services": services_dict}
66
+        )
67
+
68
+    @staticmethod
51 69
     def build_reduced_vault_config_settings(
52 70
         config: _types.VaultConfigServicesSettings,
53
-    keys_to_prune: frozenset[str],
71
+        keys_to_prune: AbstractSet[str],
54 72
     ) -> _types.VaultConfigServicesSettings:
55 73
         """Return a service settings object with certain keys pruned.
56 74
 
... ...
@@ -66,44 +84,48 @@ def build_reduced_vault_config_settings(
66 84
             config2.pop(key, None)  # type: ignore[misc]
67 85
         return config2
68 86
 
87
+    # Prefer this explicit composite strategy oven `strategies.builds`
88
+    # because the type checker can better introspect this.
89
+    @strategies.composite
90
+    @staticmethod
91
+    def services_strategy(
92
+        draw: strategies.DrawFn,
93
+    ) -> _types.VaultConfigServicesSettings:
94
+        """Return a strategy to build incomplete service configurations.
69 95
 
70
-SERVICES_STRATEGY = strategies.builds(
71
-    build_reduced_vault_config_settings,
72
-    hypothesis_machinery.vault_full_service_config(),
96
+        Args:
97
+            draw:
98
+                The `draw` function, as provided for by hypothesis.
99
+
100
+        Returns:
101
+            A strategy that generates `vault` service configurations,
102
+            with some settings left unspecified.
103
+
104
+        """
105
+        config = draw(
106
+            hypothesis_machinery.vault_full_service_config(), label="config"
107
+        )
108
+        keys_to_prune = draw(
73 109
             strategies.sets(
74
-        strategies.sampled_from(VALID_PROPERTIES),
75
-        max_size=7,
110
+                strategies.sampled_from(VALID_PROPERTIES), max_size=7
76 111
             ),
112
+            label="keys_to_prune",
77 113
         )
78
-"""A hypothesis strategy to build incomplete service configurations."""
79
-
80
-
81
-def services_strategy() -> strategies.SearchStrategy[
82
-    _types.VaultConfigServicesSettings
83
-]:
84
-    """Return a strategy to build incomplete service configurations."""
85
-    return SERVICES_STRATEGY
86
-
87
-
88
-def assemble_config(
89
-    global_data: _types.VaultConfigGlobalSettings,
90
-    service_data: list[tuple[str, _types.VaultConfigServicesSettings]],
91
-) -> _types.VaultConfig:
92
-    """Return a vault config using the global and service data."""
93
-    services_dict = dict(service_data)
94
-    return (
95
-        {"global": global_data, "services": services_dict}
96
-        if global_data
97
-        else {"services": services_dict}
114
+        # The `hypothesis.strategies.composite` decorator cannot handle
115
+        # bound class or instance methods, so we must code this as
116
+        # a static method.  As such, we cannot access sibling helper
117
+        # methods via `self` or `cls`, but must go via the class name.
118
+        return Strategies.build_reduced_vault_config_settings(
119
+            config, keys_to_prune
98 120
         )
99 121
 
100
-
101 122
     @strategies.composite
102
-def draw_service_name_and_data(
123
+    @staticmethod
124
+    def service_name_and_data_strategy(
103 125
         draw: hypothesis.strategies.DrawFn,
104 126
         num_entries: int,
105 127
     ) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]:
106
-    """Draw a service name and settings, as a hypothesis strategy.
128
+        """Return a strategy for tuples of service names and settings.
107 129
 
108 130
         Will draw service names from [`KNOWN_SERVICES`][] and service
109 131
         settings via [`services_strategy`][].
... ...
@@ -115,7 +137,8 @@ def draw_service_name_and_data(
115 137
                 The number of services to draw.
116 138
 
117 139
         Returns:
118
-        A sequence of pairs of service names and service settings.
140
+            A strategy that generates a sequence of pairs of service
141
+            names and service settings.
119 142
 
120 143
         """
121 144
         possible_services = list(KNOWN_SERVICES)
... ...
@@ -126,24 +149,55 @@ def draw_service_name_and_data(
126 149
             )
127 150
             possible_services.remove(selected_services[-1])
128 151
         return tuple(
129
-        (service, draw(services_strategy())) for service in selected_services
152
+            (service, draw(Strategies.services_strategy()))
153
+            for service in selected_services
130 154
         )
131 155
 
156
+    @strategies.composite
157
+    @staticmethod
158
+    def vault_full_config(draw: strategies.DrawFn) -> _types.VaultConfig:
159
+        """Return a strategy to build full vault configurations."""
160
+        services = draw(Strategies.services_strategy(), label="services")
161
+        num_entries = draw(
162
+            strategies.integers(min_value=2, max_value=4), label="num_entries"
163
+        )
164
+        service_name_and_data = draw(
165
+            Strategies.service_name_and_data_strategy(num_entries),
166
+            label="service_name_and_data",
167
+        )
168
+        return Strategies.assemble_config(services, service_name_and_data)
132 169
 
133
-VAULT_FULL_CONFIG = strategies.builds(
134
-    assemble_config,
135
-    services_strategy(),
136
-    strategies.integers(
137
-        min_value=2,
138
-        max_value=4,
139
-    ).flatmap(draw_service_name_and_data),
170
+    @staticmethod
171
+    def maybe_unset_strategy() -> strategies.SearchStrategy[AbstractSet[str]]:
172
+        """Return a strategy to build sets of names to maybe unset."""
173
+        return strategies.sets(
174
+            strategies.sampled_from(VALID_PROPERTIES), max_size=3
140 175
         )
141
-"""A hypothesis strategy to build full vault configurations."""
142 176
 
177
+    @staticmethod
178
+    def service_name_strategy() -> strategies.SearchStrategy[str]:
179
+        """Return a strategy for service names."""
180
+        return strategies.sampled_from(KNOWN_SERVICES)
143 181
 
144
-def vault_full_config() -> strategies.SearchStrategy[_types.VaultConfig]:
145
-    """Return a strategy to build full vault configurations."""
146
-    return VAULT_FULL_CONFIG
182
+    @staticmethod
183
+    def setting_strategy(
184
+        setting_bundle: stateful.Bundle[_types.VaultConfigServicesSettings],
185
+    ) -> strategies.SearchStrategy[_types.VaultConfigServicesSettings]:
186
+        """Return a strategy for setting objects, given a setting bundle."""
187
+        return setting_bundle.filter(bool)
188
+
189
+    @strategies.composite
190
+    @staticmethod
191
+    def config_and_service_strategy(
192
+        draw: strategies.DrawFn,
193
+        configuration: stateful.Bundle[_types.VaultConfig],
194
+    ) -> tuple[_types.VaultConfig, str]:
195
+        """Return a strategy for a vault config and a service name tuple."""
196
+        config_strategy = configuration.filter(lambda c: bool(c["services"]))
197
+        config = draw(config_strategy, label="config")
198
+        keys = tuple(config["services"].keys())
199
+        key = draw(strategies.sampled_from(keys), label="key")
200
+        return (config, key)
147 201
 
148 202
 
149 203
 class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
... ...
@@ -188,7 +242,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
188 242
     @stateful.initialize(
189 243
         target=configuration,
190 244
         configs=strategies.lists(
191
-            vault_full_config(),
245
+            Strategies.vault_full_config(),
192 246
             min_size=8,
193 247
             max_size=8,
194 248
         ),
... ...
@@ -203,7 +257,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
203 257
     @stateful.initialize(
204 258
         target=setting,
205 259
         configs=strategies.lists(
206
-            vault_full_config(),
260
+            Strategies.vault_full_config(),
207 261
             min_size=4,
208 262
             max_size=4,
209 263
         ),
... ...
@@ -233,25 +287,62 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
233 287
             "services": {**c2["services"], **c1["services"]},
234 288
         }
235 289
 
236
-    @stateful.rule(
237
-        target=configuration,
238
-        config=configuration,
239
-        setting=setting.filter(bool),
240
-        maybe_unset=strategies.sets(
241
-            strategies.sampled_from(VALID_PROPERTIES),
242
-            max_size=3,
243
-        ),
244
-        overwrite=strategies.booleans(),
290
+    def call_cli(
291
+        self,
292
+        command_line: list[str],
293
+        expected_config: _types.VaultConfig,
294
+        /,
295
+        *,
296
+        input: str | bytes | None = None,
297
+        overwrite: bool = False,
298
+    ) -> _types.VaultConfig:
299
+        """Call the command-line interface for config manipulation.
300
+
301
+        Args:
302
+            command_line:
303
+                The command-line to execute via
304
+                a [`machinery.CliRunner`][].
305
+
306
+                The `--overwrite-existing`/`--merge-existing` options
307
+                should be managed via the `overwrite` option instead of
308
+                being explicitly specified.
309
+            expected_config:
310
+                The expected configuration after calling this
311
+                command-line.
312
+
313
+        Raises:
314
+            AssertionError:
315
+                The command exited with an error status.
316
+
317
+                *Or*, the actual resulting configuration does not match
318
+                the expected configuration.
319
+
320
+        """
321
+        overwriting = (
322
+            ["--overwrite-existing"] if overwrite else ["--merge-existing"]
245 323
         )
246
-    def set_globals(
324
+        result = self.runner.invoke(
325
+            cli.derivepassphrase_vault,
326
+            [*overwriting, *command_line],
327
+            input=input,
328
+            catch_exceptions=False,
329
+        )
330
+        assert result.clean_exit(empty_stderr=False)
331
+        assert cli_helpers.load_config() == expected_config
332
+        return expected_config
333
+
334
+    def set_globals_expected_result(
247 335
         self,
248 336
         config: _types.VaultConfig,
249 337
         setting: _types.VaultConfigGlobalSettings,
250
-        maybe_unset: set[str],
338
+        maybe_unset: AbstractSet[str],
251 339
         overwrite: bool,
252
-    ) -> _types.VaultConfig:
340
+    ) -> tuple[_types.VaultConfig, AbstractSet[str]]:
253 341
         """Set the global settings of a configuration.
254 342
 
343
+        This is the "calculate the correct result" section of the
344
+        `set_globals` rule.
345
+
255 346
         Args:
256 347
             config:
257 348
                 The configuration to edit.
... ...
@@ -267,10 +358,12 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
267 358
                 `--merge-existing` command-line arguments.
268 359
 
269 360
         Returns:
270
-            The amended configuration.
361
+            A 2-tuple containing the amended configuration, then the
362
+            settings keys that were actually unset.
271 363
 
272 364
         """
273
-        cli_helpers.save_config(config)
365
+        config = copy.deepcopy(config)
366
+        setting = copy.deepcopy(setting)
274 367
         config_global = config.get("global", {})
275 368
         maybe_unset = set(maybe_unset) - setting.keys()
276 369
         if overwrite:
... ...
@@ -280,36 +373,116 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
280 373
                 config_global.pop(key, None)  # type: ignore[misc]
281 374
         config.setdefault("global", {}).update(setting)
282 375
         assert _types.is_vault_config(config)
283
-        # NOTE: This relies on settings_obj containing only the keys
376
+        return config, maybe_unset
377
+
378
+    @stateful.rule(
379
+        target=configuration,
380
+        config=configuration,
381
+        setting=Strategies.setting_strategy(setting),
382
+        maybe_unset=Strategies.maybe_unset_strategy(),
383
+        overwrite=strategies.booleans(),
384
+    )
385
+    def set_globals(
386
+        self,
387
+        config: _types.VaultConfig,
388
+        setting: _types.VaultConfigGlobalSettings,
389
+        maybe_unset: AbstractSet[str],
390
+        overwrite: bool,
391
+    ) -> _types.VaultConfig:
392
+        """Set the global settings of a configuration.
393
+
394
+        Args:
395
+            config:
396
+                The configuration to edit.
397
+            setting:
398
+                The new global settings.
399
+            maybe_unset:
400
+                Settings keys to additionally unset, if not already
401
+                present in the new settings.  Corresponds to the
402
+                `--unset` command-line argument.
403
+            overwrite:
404
+                Overwrite the settings object if true, or merge if
405
+                false.  Corresponds to the `--overwrite-existing` and
406
+                `--merge-existing` command-line arguments.
407
+
408
+        Returns:
409
+            The amended configuration.
410
+
411
+        """
412
+        cli_helpers.save_config(config)
413
+        expected_config, maybe_unset = self.set_globals_expected_result(
414
+            config=config,
415
+            setting=setting,
416
+            maybe_unset=maybe_unset,
417
+            overwrite=overwrite,
418
+        )
419
+        # NOTE: This relies on `settings` containing only the keys
284 420
         # "length", "repeat", "upper", "lower", "number", "space",
285 421
         # "dash" and "symbol".
286
-        result = self.runner.invoke(
287
-            cli.derivepassphrase_vault,
288
-            [
289
-                "--config",
290
-                "--overwrite-existing" if overwrite else "--merge-existing",
291
-            ]
292
-            + [f"--unset={key}" for key in maybe_unset]
293
-            + [
422
+        unset_commands = [f"--unset={key}" for key in maybe_unset]
423
+        set_commands = [
294 424
             f"--{key}={value}"
295 425
             for key, value in setting.items()
296 426
             if key in VALID_PROPERTIES
297
-            ],
298
-            catch_exceptions=False,
427
+        ]
428
+        return self.call_cli(
429
+            ["--config", *unset_commands, *set_commands],
430
+            expected_config,
431
+            overwrite=overwrite,
299 432
         )
300
-        assert result.clean_exit(empty_stderr=False)
301
-        assert cli_helpers.load_config() == config
302
-        return config
433
+
434
+    def set_service_expected_result(
435
+        self,
436
+        service: str,
437
+        config: _types.VaultConfig,
438
+        setting: _types.VaultConfigGlobalSettings,
439
+        maybe_unset: AbstractSet[str],
440
+        overwrite: bool,
441
+    ) -> tuple[_types.VaultConfig, AbstractSet[str]]:
442
+        """Set the named service settings for a configuration.
443
+
444
+        This is the "calculate the correct result" section of the
445
+        `set_service` rule.
446
+
447
+        Args:
448
+            config:
449
+                The configuration to edit.
450
+            service:
451
+                The name of the service to set.
452
+            setting:
453
+                The new service settings.
454
+            maybe_unset:
455
+                Settings keys to additionally unset, if not already
456
+                present in the new settings.  Corresponds to the
457
+                `--unset` command-line argument.
458
+            overwrite:
459
+                Overwrite the settings object if true, or merge if
460
+                false.  Corresponds to the `--overwrite-existing` and
461
+                `--merge-existing` command-line arguments.
462
+
463
+        Returns:
464
+            A 2-tuple containing the amended configuration, then the
465
+            settings keys that were actually unset.
466
+
467
+        """
468
+        config = copy.deepcopy(config)
469
+        config_service = config["services"].get(service, {})
470
+        maybe_unset = set(maybe_unset) - setting.keys()
471
+        if overwrite:
472
+            config["services"][service] = config_service = {}
473
+        elif maybe_unset:
474
+            for key in maybe_unset:
475
+                config_service.pop(key, None)  # type: ignore[misc]
476
+        config["services"].setdefault(service, {}).update(setting)
477
+        assert _types.is_vault_config(config)
478
+        return config, maybe_unset
303 479
 
304 480
     @stateful.rule(
305 481
         target=configuration,
306 482
         config=configuration,
307
-        service=strategies.sampled_from(KNOWN_SERVICES),
308
-        setting=setting.filter(bool),
309
-        maybe_unset=strategies.sets(
310
-            strategies.sampled_from(VALID_PROPERTIES),
311
-            max_size=3,
312
-        ),
483
+        service=Strategies.service_name_strategy(),
484
+        setting=Strategies.setting_strategy(setting),
485
+        maybe_unset=Strategies.maybe_unset_strategy(),
313 486
         overwrite=strategies.booleans(),
314 487
     )
315 488
     def set_service(
... ...
@@ -317,7 +490,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
317 490
         config: _types.VaultConfig,
318 491
         service: str,
319 492
         setting: _types.VaultConfigServicesSettings,
320
-        maybe_unset: set[str],
493
+        maybe_unset: AbstractSet[str],
321 494
         overwrite: bool,
322 495
     ) -> _types.VaultConfig:
323 496
         """Set the named service settings for a configuration.
... ...
@@ -343,35 +516,48 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
343 516
 
344 517
         """
345 518
         cli_helpers.save_config(config)
346
-        config_service = config["services"].get(service, {})
347
-        maybe_unset = set(maybe_unset) - setting.keys()
348
-        if overwrite:
349
-            config["services"][service] = config_service = {}
350
-        elif maybe_unset:
351
-            for key in maybe_unset:
352
-                config_service.pop(key, None)  # type: ignore[misc]
353
-        config["services"].setdefault(service, {}).update(setting)
354
-        assert _types.is_vault_config(config)
355
-        # NOTE: This relies on settings_obj containing only the keys
519
+        expected_config, maybe_unset = self.set_service_expected_result(
520
+            service,
521
+            config=config,
522
+            setting=setting,
523
+            maybe_unset=maybe_unset,
524
+            overwrite=overwrite,
525
+        )
526
+        # NOTE: This relies on `settings` containing only the keys
356 527
         # "length", "repeat", "upper", "lower", "number", "space",
357 528
         # "dash" and "symbol".
358
-        result = self.runner.invoke(
359
-            cli.derivepassphrase_vault,
360
-            [
361
-                "--config",
362
-                "--overwrite-existing" if overwrite else "--merge-existing",
363
-            ]
364
-            + [f"--unset={key}" for key in maybe_unset]
365
-            + [
529
+        unset_commands = [f"--unset={key}" for key in maybe_unset]
530
+        set_commands = [
366 531
             f"--{key}={value}"
367 532
             for key, value in setting.items()
368 533
             if key in VALID_PROPERTIES
369 534
         ]
370
-            + ["--", service],
371
-            catch_exceptions=False,
535
+        service_arg = ["--", service]
536
+        return self.call_cli(
537
+            ["--config", *unset_commands, *set_commands, *service_arg],
538
+            expected_config,
539
+            overwrite=overwrite,
372 540
         )
373
-        assert result.clean_exit(empty_stderr=False)
374
-        assert cli_helpers.load_config() == config
541
+
542
+    def purge_global_expected_result(
543
+        self,
544
+        config: _types.VaultConfig,
545
+    ) -> _types.VaultConfig:
546
+        """Purge the globals of a configuration.
547
+
548
+        This is the "calculate the correct result" section of the
549
+        `purge_global` rule.
550
+
551
+        Args:
552
+            config:
553
+                The configuration to edit.
554
+
555
+        Returns:
556
+            The pruned configuration.
557
+
558
+        """
559
+        config = copy.deepcopy(config)
560
+        config.pop("global", None)
375 561
         return config
376 562
 
377 563
     @stateful.rule(
... ...
@@ -393,26 +579,37 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
393 579
 
394 580
         """
395 581
         cli_helpers.save_config(config)
396
-        config.pop("global", None)
397
-        result = self.runner.invoke(
398
-            cli.derivepassphrase_vault,
399
-            ["--delete-globals"],
400
-            input="y",
401
-            catch_exceptions=False,
402
-        )
403
-        assert result.clean_exit(empty_stderr=False)
404
-        assert cli_helpers.load_config() == config
582
+        expected_config = self.purge_global_expected_result(config)
583
+        return self.call_cli(["--delete-globals"], expected_config)
584
+
585
+    def purge_service_expected_result(
586
+        self,
587
+        config: _types.VaultConfig,
588
+        service: str,
589
+    ) -> _types.VaultConfig:
590
+        """Purge the settings of a named service in a configuration.
591
+
592
+        This is the "calculate the correct result" section of the
593
+        `purge_global` rule.
594
+
595
+        Args:
596
+            config:
597
+                The configuration to edit.
598
+            service:
599
+                The service name to purge.
600
+
601
+        Returns:
602
+            The pruned configuration.
603
+
604
+        """
605
+        config = copy.deepcopy(config)
606
+        config["services"].pop(service, None)
405 607
         return config
406 608
 
407 609
     @stateful.rule(
408 610
         target=configuration,
409
-        config_and_service=configuration.filter(
410
-            lambda c: bool(c["services"])
411
-        ).flatmap(
412
-            lambda c: strategies.tuples(
413
-                strategies.just(c),
414
-                strategies.sampled_from(tuple(c["services"].keys())),
415
-            )
611
+        config_and_service=Strategies.config_and_service_strategy(
612
+            configuration
416 613
         ),
417 614
     )
418 615
     def purge_service(
... ...
@@ -432,16 +629,20 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
432 629
         """
433 630
         config, service = config_and_service
434 631
         cli_helpers.save_config(config)
435
-        config["services"].pop(service, None)
436
-        result = self.runner.invoke(
437
-            cli.derivepassphrase_vault,
438
-            ["--delete", "--", service],
439
-            input="y",
440
-            catch_exceptions=False,
441
-        )
442
-        assert result.clean_exit(empty_stderr=False)
443
-        assert cli_helpers.load_config() == config
444
-        return config
632
+        expected_config = self.purge_service_expected_result(config, service)
633
+        return self.call_cli(["--delete", "--", service], expected_config)
634
+
635
+    def purge_all_expected_result(self) -> _types.VaultConfig:
636
+        """Purge the entire configuration.
637
+
638
+        This is the "calculate the correct result" section of the
639
+        `purge_all` rule.
640
+
641
+        Returns:
642
+            The empty configuration.
643
+
644
+        """
645
+        return {"services": {}}
445 646
 
446 647
     @stateful.rule(
447 648
         target=configuration,
... ...
@@ -462,16 +663,41 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
462 663
 
463 664
         """
464 665
         cli_helpers.save_config(config)
465
-        config = {"services": {}}
466
-        result = self.runner.invoke(
467
-            cli.derivepassphrase_vault,
468
-            ["--clear"],
469
-            input="y",
470
-            catch_exceptions=False,
666
+        expected_config = self.purge_all_expected_result()
667
+        return self.call_cli(["--clear"], expected_config)
668
+
669
+    def import_configuration_expected_result(
670
+        self,
671
+        base_config: _types.VaultConfig,
672
+        config_to_import: _types.VaultConfig,
673
+        overwrite: bool,
674
+    ) -> _types.VaultConfig:
675
+        """Import the given configuration into a base configuration.
676
+
677
+        This is the "calculate the correct result" section of the
678
+        `import_configuration` rule.
679
+
680
+        Args:
681
+            base_config:
682
+                The configuration to import into.
683
+            config_to_import:
684
+                The configuration to import.
685
+            overwrite:
686
+                Overwrite the base configuration if true, or merge if
687
+                false.  Corresponds to the `--overwrite-existing` and
688
+                `--merge-existing` command-line arguments.
689
+
690
+        Returns:
691
+            The imported or merged configuration.
692
+
693
+        """
694
+        result = (
695
+            copy.deepcopy(config_to_import)
696
+            if overwrite
697
+            else self.fold_configs(config_to_import, base_config)
471 698
         )
472
-        assert result.clean_exit(empty_stderr=False)
473
-        assert cli_helpers.load_config() == config
474
-        return config
699
+        assert _types.is_vault_config(result)
700
+        return result
475 701
 
476 702
     @stateful.rule(
477 703
         target=configuration,
... ...
@@ -502,22 +728,17 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
502 728
 
503 729
         """
504 730
         cli_helpers.save_config(base_config)
505
-        config = (
506
-            self.fold_configs(config_to_import, base_config)
507
-            if not overwrite
508
-            else config_to_import
509
-        )
510
-        assert _types.is_vault_config(config)
511
-        result = self.runner.invoke(
512
-            cli.derivepassphrase_vault,
513
-            ["--import", "-"]
514
-            + (["--overwrite-existing"] if overwrite else []),
731
+        expected_config = self.import_configuration_expected_result(
732
+            base_config=base_config,
733
+            config_to_import=config_to_import,
734
+            overwrite=overwrite,
735
+        )
736
+        return self.call_cli(
737
+            ["--import", "-"],
738
+            expected_config,
515 739
             input=json.dumps(config_to_import),
516
-            catch_exceptions=False,
740
+            overwrite=overwrite,
517 741
         )
518
-        assert result.clean_exit(empty_stderr=False)
519
-        assert cli_helpers.load_config() == config
520
-        return config
521 742
 
522 743
     def teardown(self) -> None:
523 744
         """Upon teardown, exit all contexts entered in `__init__`."""
... ...
@@ -806,10 +1027,26 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
806 1027
             settings
807 1028
         )
808 1029
 
1030
+    @staticmethod
1031
+    def overwrite_or_merge_commands(*, overwrite: bool = False) -> list[str]:
1032
+        """Return a partial command-line for overwriting or merging.
1033
+
1034
+        Args:
1035
+            overwrite:
1036
+                If true, overwrite the configuration, else merge in the
1037
+                relevant parts.
1038
+
1039
+        Returns:
1040
+            A list of command-line options for selecting config
1041
+            overwriting or config merging.
1042
+
1043
+        """
1044
+        return ["--overwrite-existing"] if overwrite else ["--merge-existing"]
1045
+
809 1046
     @stateful.initialize(
810 1047
         target=configuration,
811 1048
         configs=strategies.lists(
812
-            vault_full_config(),
1049
+            Strategies.vault_full_config(),
813 1050
             min_size=8,
814 1051
             max_size=8,
815 1052
         ),
... ...
@@ -824,7 +1061,7 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
824 1061
     @stateful.initialize(
825 1062
         target=setting,
826 1063
         configs=strategies.lists(
827
-            vault_full_config(),
1064
+            Strategies.vault_full_config(),
828 1065
             min_size=4,
829 1066
             max_size=4,
830 1067
         ),
... ...
@@ -840,7 +1077,7 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
840 1077
         return stateful.multiple(*map(copy.deepcopy, settings))
841 1078
 
842 1079
     @stateful.initialize(
843
-        config=vault_full_config(),
1080
+        config=Strategies.vault_full_config(),
844 1081
     )
845 1082
     def declare_initial_action(
846 1083
         self,
... ...
@@ -865,11 +1102,8 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
865 1102
         self.actions.append(action)
866 1103
 
867 1104
     @stateful.rule(
868
-        setting=setting.filter(bool),
869
-        maybe_unset=strategies.sets(
870
-            strategies.sampled_from(VALID_PROPERTIES),
871
-            max_size=3,
872
-        ),
1105
+        setting=Strategies.setting_strategy(setting),
1106
+        maybe_unset=Strategies.maybe_unset_strategy(),
873 1107
         overwrite=strategies.booleans(),
874 1108
     )
875 1109
     def add_set_globals_action(
... ...
@@ -894,18 +1128,21 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
894 1128
 
895 1129
         """
896 1130
         maybe_unset = set(maybe_unset) - setting.keys()
897
-        command_line = (
898
-            [
899
-                "--config",
900
-                "--overwrite-existing" if overwrite else "--merge-existing",
901
-            ]
902
-            + [f"--unset={key}" for key in maybe_unset]
903
-            + [
1131
+        # NOTE: This relies on `settings` containing only the keys
1132
+        # "length", "repeat", "upper", "lower", "number", "space",
1133
+        # "dash" and "symbol".
1134
+        unset_commands = [f"--unset={key}" for key in maybe_unset]
1135
+        set_commands = [
904 1136
             f"--{key}={value}"
905 1137
             for key, value in setting.items()
906 1138
             if key in VALID_PROPERTIES
907 1139
         ]
908
-        )
1140
+        command_line = [
1141
+            "--config",
1142
+            *self.overwrite_or_merge_commands(overwrite=overwrite),
1143
+            *unset_commands,
1144
+            *set_commands,
1145
+        ]
909 1146
         input = None  # noqa: A001
910 1147
         hypothesis.note(f"# {command_line = }, {input = }")
911 1148
         action = FakeConfigurationMutexAction(
... ...
@@ -914,12 +1151,9 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
914 1151
         self.actions.append(action)
915 1152
 
916 1153
     @stateful.rule(
917
-        service=strategies.sampled_from(KNOWN_SERVICES),
918
-        setting=setting.filter(bool),
919
-        maybe_unset=strategies.sets(
920
-            strategies.sampled_from(VALID_PROPERTIES),
921
-            max_size=3,
922
-        ),
1154
+        service=Strategies.service_name_strategy(),
1155
+        setting=Strategies.setting_strategy(setting),
1156
+        maybe_unset=Strategies.maybe_unset_strategy(),
923 1157
         overwrite=strategies.booleans(),
924 1158
     )
925 1159
     def add_set_service_action(
... ...
@@ -947,19 +1181,23 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
947 1181
 
948 1182
         """
949 1183
         maybe_unset = set(maybe_unset) - setting.keys()
950
-        command_line = (
951
-            [
952
-                "--config",
953
-                "--overwrite-existing" if overwrite else "--merge-existing",
954
-            ]
955
-            + [f"--unset={key}" for key in maybe_unset]
956
-            + [
1184
+        # NOTE: This relies on `settings` containing only the keys
1185
+        # "length", "repeat", "upper", "lower", "number", "space",
1186
+        # "dash" and "symbol".
1187
+        unset_commands = [f"--unset={key}" for key in maybe_unset]
1188
+        set_commands = [
957 1189
             f"--{key}={value}"
958 1190
             for key, value in setting.items()
959 1191
             if key in VALID_PROPERTIES
960 1192
         ]
961
-            + ["--", service]
962
-        )
1193
+        command_line = [
1194
+            "--config",
1195
+            *self.overwrite_or_merge_commands(overwrite=overwrite),
1196
+            *unset_commands,
1197
+            *set_commands,
1198
+            "--",
1199
+            service,
1200
+        ]
963 1201
         input = None  # noqa: A001
964 1202
         hypothesis.note(f"# {command_line = }, {input = }")
965 1203
         action = FakeConfigurationMutexAction(
... ...
@@ -981,7 +1219,7 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
981 1219
         self.actions.append(action)
982 1220
 
983 1221
     @stateful.rule(
984
-        service=strategies.sampled_from(KNOWN_SERVICES),
1222
+        service=Strategies.service_name_strategy(),
985 1223
     )
986 1224
     def add_purge_service_action(
987 1225
         self,
... ...
@@ -1035,9 +1273,11 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
1035 1273
                 `--merge-existing` command-line arguments.
1036 1274
 
1037 1275
         """
1038
-        command_line = ["--import", "-"] + (
1039
-            ["--overwrite-existing"] if overwrite else []
1040
-        )
1276
+        command_line = [
1277
+            "--import",
1278
+            "-",
1279
+            *self.overwrite_or_merge_commands(overwrite=overwrite),
1280
+        ]
1041 1281
         input = json.dumps(config_to_import)  # noqa: A001
1042 1282
         hypothesis.note(f"# {command_line = }, {input = }")
1043 1283
         action = FakeConfigurationMutexAction(
1044 1284