Add more translatable strings and serialization machinery
Marco Ricci

Marco Ricci commited on 2024-12-30 21:34:42
Zeige 3 geänderte Dateien mit 747 Einfügungen und 114 Löschungen.


Add all help texts and metavars as translatable strings.  Furthermore,
add extra machinery for introspection to the serialized translatable
strings class, which will make debugging values in pytest much easier.

Add another couple of error messages (and update the manpages as well)
for low-level errors while querying the SSH agent for the key list or
while requesting a signature.  (Previously, we forwarded the internal
English exception message directly.)  Update the manpage to include
these as well.
... ...
@@ -50,24 +50,43 @@
50 50
 .
51 51
 .Sh DESCRIPTION
52 52
 .
53
-Using a master passphrase or a master
54
-.Tn SSH
55
-key, derive a passphrase for
53
+Using a master passphrase, derive a passphrase for
56 54
 .Ar SERVICE ,
57 55
 subject to length, character and character repetition constraints, in a
58 56
 manner compatible with James Coglan's
59 57
 .Xr vault 1 .
60 58
 .Pp
61 59
 .
62
-The derivation is cryptographically strong, meaning that even if a single
63
-passphrase is compromised, guessing the master passphrase or a different
64
-service's passphrase is computationally infeasible.
65
-The derivation is also deterministic, given the same inputs, thus the
66
-resulting passphrase need not be stored explicitly.
60
+The derivation is
61
+.Em strong :
62
+derived passphrases have as much entropy as permitted by the master
63
+passphrase and the passphrase constraints (whichever is more restrictive),
64
+and even if multiple derived passphrases are compromised, the master
65
+passphrase remains cryptographically difficult to discern from theses
66
+compromised passphrases.
67
+The derivation is also
68
+.Em deterministic ,
69
+given the same inputs, thus the resulting passphrase need not be stored
70
+explicitly.
67 71
 .Pp
68 72
 .
69 73
 The service name and constraints themselves also need not be kept secret;
70
-the latter are usually stored in a world-readable file.
74
+the latter are usually stored in a world-readable file to ease repeated
75
+entry of passphrase constraints.
76
+.Pp
77
+.
78
+In lieu of a master passphrase, a master
79
+.Tn SSH
80
+key can also be used if there is a reachable, running
81
+.Tn SSH
82
+agent currently holding this key and if the key type is supported.
83
+(See
84
+.Sx "SSH KEY SUITABILITY"
85
+and
86
+.Sx BUGS
87
+below.)
88
+This too is compatible with
89
+.Xr vault 1 .
71 90
 .
72 91
 .Sh OPTIONS
73 92
 .
... ...
@@ -677,13 +696,37 @@ We cannot connect to the SSH agent indicated by the
677 696
 environment variable.
678 697
 Further details are contained in the variable part of the message.
679 698
 .
680
-.It The SSH agent failed to complete the request
681
-The SSH agent \(em while responsive in principle \(em failed to or refused
682
-to supply a list of loaded keys.
699
+.It The SSH agent failed to or refused to supply a list of loaded keys.
700
+The SSH agent \(em while responsive in principle \(em did not fulfill the
701
+request.
702
+.
703
+.It "The SSH agent failed to or refused to" "issue a signature with the selected key," "necessary for deriving a service passphrase."
704
+The SSH agent \(em while responsive in principle \(em failed to cooperate with
705
+deriving a service passphrase from the selected master
706
+.Tn SSH
707
+key.
708
+.
709
+.It The SSH agent contains no keys suitable for derivepassphrase.
710
+.
711
+None of the keys loaded into the
712
+.Tn SSH
713
+agent (if any) are suitable for use with
714
+.Nm derivepassphrase vault .
715
+See the
716
+.Sx "SSH KEY SUITABILITY"
717
+section for the requirements the
718
+.Tn SSH
719
+key and the
720
+.Tn SSH
721
+agent must fulfill to be suitable.
683 722
 .
684 723
 .It Error communicating with the SSH agent
685 724
 There was a system error communicating with the SSH agent.
686 725
 .
726
+.It Cannot understand the SSH agent's response because it violates the communication protocol.
727
+.
728
+(Exactly what it says.)
729
+.
687 730
 .It Not saving any new notes: the user aborted the request.
688 731
 (Exactly what it says.)
689 732
 .
... ...
@@ -15,12 +15,14 @@ derivepassphrase-vault – derive a passphrase using the vault derivation scheme
15 15
 
16 16
 ## DESCRIPTION
17 17
 
18
-Using a master passphrase or a master SSH key, derive a passphrase for <var>SERVICE</var>, subject to length, character and character repetition constraints, in a manner compatible with James Coglan's <i>vault</i>(1).
18
+Using a master passphrase, derive a passphrase for <var>SERVICE</var>, subject to length, character and character repetition constraints, in a manner compatible with James Coglan's <i>vault</i>(1).
19 19
 
20
-The derivation is cryptographically strong, meaning that even if a single passphrase is compromised, guessing the master passphrase or a different service's passphrase is computationally infeasible.
21
-The derivation is also deterministic, given the same inputs, thus the resulting passphrase need not be stored explicitly.
20
+The derivation is <em>strong</em>: derived passphrases have as much entropy as permitted by the master passphrase and the passphrase constraints (whichever is more restrictive), and even if multiple derived passphrases are compromised, the master passphrase remains cryptographically difficult to discern from these compromised passphrases.
21
+The derivation is also <em>deterministic</em>, given the same inputs, thus the resulting passphrase need not be stored explicitly.
22 22
 
23
-The service name and constraints themselves also need not be kept secret; the latter are usually stored in a world-readable file.
23
+The service name and constraints themselves also need not be kept secret; the latter are usually stored in a world-readable file to ease repeated entry of passphrase constraints.
24
+
25
+In lieu of a master passphrase, a master SSH key can also be used if there is a reachable, running SSH agent currently holding this key and if the key type is supported.  (See ["SSH KEY SUITABILITY"](#ssh-key-suitability) and ["BUGS"](#bugs) below.)  This too is compatible with <i>vault</i>(1).
24 26
 
25 27
 ## OPTIONS
26 28
 
... ...
@@ -238,7 +240,7 @@ This is a property specific to the key type, and sometimes the agent used:
238 240
 
239 241
 ## DIAGNOSTICS
240 242
 
241
-The derivepassphrase vault utility exits 0 on success, and >0 if an error occurs.
243
+The <b>derivepassphrase vault</b> utility exits 0 on success, and >0 if an error occurs.
242 244
 
243 245
 ### Fatal error messsages on standard error
244 246
 
... ...
@@ -314,14 +316,26 @@ The derivepassphrase vault utility exits 0 on success, and >0 if an error occurs
314 316
     We cannot connect to the SSH agent indicated by the `SSH_AUTH_SOCK` environment variable.
315 317
     Further details are contained in the variable part of the message.
316 318
 
317
-??? failure "`The SSH agent failed to complete the request`"
319
+??? failure "`The SSH agent failed to or refused to supply a list of loaded keys.`"
320
+
321
+    The SSH agent---while responsive in principle---did not fulfill the request.
322
+
323
+??? failure "`The SSH agent failed to or refused to issue a signature with the selected key, necessary for deriving a service passphrase.`"
324
+
325
+    The SSH agent---while responsive in principle---failed to cooperate with deriving a service passphrase from the selected master SSH key.
318 326
 
319
-    The SSH agent---while responsive in principle---failed to or refused to supply a list of loaded keys.
327
+??? failure "`The SSH agent contains no keys suitable for derivepassphrase.`"
328
+
329
+    None of the keys loaded into the SSH agent (if any) are suitable for use with <b>derivepassphrase vault</b>.  See the ["SSH KEY SUITABILITY"](#ssh-key-suitability) section for the requirements the SSH key and the SSH agent must fulfill to be suitable.
320 330
 
321 331
 ??? failure "`Error communicating with the SSH agent`"
322 332
 
323 333
     There was a system error communicating with the SSH agent.
324 334
 
335
+??? failure "`Cannot understand the SSH agent's response because it violates the communication protocol.`"
336
+
337
+    (Exactly what it says.)
338
+
325 339
 ??? failure "`Not saving any new notes: the user aborted the request.`"
326 340
 
327 341
     (Exactly what it says.)
... ...
@@ -9,8 +9,9 @@ from __future__ import annotations
9 9
 import enum
10 10
 import gettext
11 11
 import inspect
12
+import textwrap
12 13
 import types
13
-from typing import TYPE_CHECKING, NamedTuple
14
+from typing import TYPE_CHECKING, NamedTuple, cast
14 15
 
15 16
 import derivepassphrase as dpp
16 17
 
... ...
@@ -44,8 +45,26 @@ def _prepare_translatable(
44 45
     *,
45 46
     flags: Iterable[str] = (),
46 47
 ) -> TranslatableString:
47
-    msg = inspect.cleandoc(msg)
48
-    plural_msg = inspect.cleandoc(plural_msg)
48
+    def maybe_rewrap(string: str) -> str:
49
+        string = inspect.cleandoc(string)
50
+        if not any(s.strip() == '\b' for s in string.splitlines()):
51
+            string = '\n'.join(
52
+                textwrap.wrap(
53
+                    string,
54
+                    width=float('inf'),  # type: ignore[arg-type]
55
+                    fix_sentence_endings=True,
56
+                )
57
+            )
58
+        else:  # pragma: no cover
59
+            string = ''.join(
60
+                s
61
+                for s in string.splitlines(True)  # noqa: FBT003
62
+                if s.strip() == '\b'
63
+            )
64
+        return string
65
+
66
+    msg = maybe_rewrap(msg)
67
+    plural_msg = maybe_rewrap(plural_msg)
49 68
     context = context.strip()
50 69
     comments = inspect.cleandoc(comments)
51 70
     flags = (
... ...
@@ -53,44 +72,73 @@ def _prepare_translatable(
53 72
         if not isinstance(flags, str)
54 73
         else frozenset({flags})
55 74
     )
56
-    assert (
57
-        '{' not in msg
58
-        or bool(flags & {'python-brace-format', 'no-python-brace-format'})
75
+    assert '{' not in msg or bool(
76
+        flags & {'python-brace-format', 'no-python-brace-format'}
59 77
     ), f'Missing flag for how to deal with brace in {msg!r}'
60
-    assert (
61
-        '%' not in msg
62
-        or bool(flags & {'python-format', 'no-python-format'})
78
+    assert '%' not in msg or bool(
79
+        flags & {'python-format', 'no-python-format'}
63 80
     ), f'Missing flag for how to deal with percent character in {msg!r}'
64 81
     return TranslatableString(msg, plural_msg, context, comments, flags)
65 82
 
66 83
 
67
-class LogObject:
68
-
84
+class TranslatedString:
69 85
     def __init__(
70 86
         self,
71
-        template: TranslatableString,
87
+        template: (
88
+            str
89
+            | TranslatableString
90
+            | Label
91
+            | InfoMsgTemplate
92
+            | WarnMsgTemplate
93
+            | ErrMsgTemplate
94
+        ),
72 95
         args_dict: Mapping[str, Any] = types.MappingProxyType({}),
73 96
         /,
74 97
         **kwargs: Any,  # noqa: ANN401
75 98
     ) -> None:
99
+        if isinstance(
100
+            template, (Label, InfoMsgTemplate, WarnMsgTemplate, ErrMsgTemplate)
101
+        ):
102
+            template = cast(TranslatableString, template.value)
76 103
         self.template = template
77 104
         self.kwargs = {**args_dict, **kwargs}
78 105
         self._rendered: str | None = None
79 106
 
107
+    def __bool__(self) -> bool:
108
+        return bool(str(self))
109
+
110
+    def __eq__(self, other: object) -> bool:  # pragma: no cover
111
+        return str(self) == other
112
+
113
+    def __hash__(self) -> int:  # pragma: no cover
114
+        return hash(str(self))
115
+
116
+    def __repr__(self) -> str:  # pragma: no cover
117
+        return (
118
+            f'{self.__class__.__name__}({self.template!r}, '
119
+            f'{dict(self.kwargs)!r})'
120
+        )
121
+
80 122
     def __str__(self) -> str:
81 123
         if self._rendered is None:
124
+            # raw str support is currently unneeded, so excluded from coverage
125
+            if isinstance(self.template, str):  # pragma: no cover
126
+                context = None
127
+                template = self.template
128
+            else:
82 129
                 context = self.template.l10n_context
83 130
                 template = self.template.singular
84 131
             if context is not None:
85 132
                 template = translation.pgettext(context, template)
86
-            else:
133
+            else:  # pragma: no cover
87 134
                 template = translation.gettext(template)
88 135
             self._rendered = template.format(**self.kwargs)
89 136
         return self._rendered
90 137
 
91 138
     def maybe_without_filename(self) -> Self:
92 139
         if (
93
-            self.kwargs.get('filename') is None
140
+            not isinstance(self.template, str)
141
+            and self.kwargs.get('filename') is None
94 142
             and ': {filename!r}' in self.template.singular
95 143
         ):
96 144
             singular = ''.join(
... ...
@@ -107,35 +155,487 @@ class LogObject:
107 155
             )
108 156
         return self
109 157
 
110
-    @classmethod
111
-    def InfoMsg(  # noqa: N802
112
-        cls,
113
-        msg_template: InfoMsgTemplate,
114
-        args_dict: Mapping[str, Any] = types.MappingProxyType({}),
115
-        /,
116
-        **kwargs: Any,  # noqa: ANN401
117
-    ) -> Self:
118
-        return cls(msg_template.value, {**args_dict, **kwargs})
119 158
 
120
-    @classmethod
121
-    def WarnMsg(  # noqa: N802
122
-        cls,
123
-        msg_template: WarnMsgTemplate,
124
-        args_dict: Mapping[str, Any] = types.MappingProxyType({}),
125
-        /,
126
-        **kwargs: Any,  # noqa: ANN401
127
-    ) -> Self:
128
-        return cls(msg_template.value, {**args_dict, **kwargs})
159
+class Label(enum.Enum):
160
+    DEPRECATION_WARNING_LABEL = _prepare_translatable(
161
+        'Deprecation warning', comments='', context='diagnostic label'
162
+    )
163
+    WARNING_LABEL = _prepare_translatable(
164
+        'Warning', comments='', context='diagnostic label'
165
+    )
166
+    DERIVEPASSPHRASE_01 = _prepare_translatable(
167
+        msg="""
168
+        Derive a strong passphrase, deterministically, from a master secret.
169
+        """,
170
+        comments='',
171
+        context='help text (long form)',
172
+    )
173
+    DERIVEPASSPHRASE_02 = _prepare_translatable(
174
+        msg="""
175
+        The currently implemented subcommands are "vault" (for the
176
+        scheme used by vault) and "export" (for exporting foreign
177
+        configuration data).  See the respective `--help` output for
178
+        instructions.  If no subcommand is given, we default to "vault".
179
+        """,
180
+        comments='',
181
+        context='help text (long form)',
182
+    )
183
+    DERIVEPASSPHRASE_03 = _prepare_translatable(
184
+        msg="""
185
+        Deprecation notice: Defaulting to "vault" is deprecated.
186
+        Starting in v1.0, the subcommand must be specified explicitly.
187
+        """,
188
+        comments='',
189
+        context='help text (long form)',
190
+    )
191
+    DERIVEPASSPHRASE_EPILOG_01 = _prepare_translatable(
192
+        msg=r"""
193
+        Configuration is stored in a directory according to the
194
+        `DERIVEPASSPHRASE_PATH` variable, which defaults to
195
+        `~/.derivepassphrase` on UNIX-like systems and
196
+        `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
197
+        """,
198
+        comments='',
199
+        context='help text (long form)',
200
+    )
201
+    DERIVEPASSPHRASE_EXPORT_01 = _prepare_translatable(
202
+        msg="""
203
+        Export a foreign configuration to standard output.
204
+        """,
205
+        comments='',
206
+        context='help text (long form)',
207
+    )
208
+    DERIVEPASSPHRASE_EXPORT_02 = _prepare_translatable(
209
+        msg="""
210
+        The only available subcommand is "vault", which implements the
211
+        vault-native configuration scheme.  If no subcommand is given,
212
+        we default to "vault".
213
+        """,
214
+        comments='',
215
+        context='help text (long form)',
216
+    )
217
+    DERIVEPASSPHRASE_EXPORT_03 = DERIVEPASSPHRASE_03
218
+    DERIVEPASSPHRASE_EXPORT_VAULT_01 = _prepare_translatable(
219
+        msg="""
220
+        Export a vault-native configuration to standard output.
221
+        """,
222
+        comments='',
223
+        context='help text (long form)',
224
+    )
225
+    DERIVEPASSPHRASE_EXPORT_VAULT_02 = _prepare_translatable(
226
+        msg="""
227
+        Depending on the configuration format, {path_metavar!s} may
228
+        either be a file or a directory.  We support the vault "v0.2",
229
+        "v0.3" and "storeroom" formats.
230
+        """,
231
+        comments='',
232
+        context='help text (long form)',
233
+        flags='python-brace-format',
234
+    )
235
+    DERIVEPASSPHRASE_EXPORT_VAULT_03 = _prepare_translatable(
236
+        msg="""
237
+        If {path_metavar!s} is explicitly given as `VAULT_PATH`, then
238
+        use the `VAULT_PATH` environment variable to determine the
239
+        correct path.  (Use `./VAULT_PATH` or similar to indicate
240
+        a file/directory actually named `VAULT_PATH`.)
241
+        """,
242
+        comments='',
243
+        context='help text (long form)',
244
+        flags='python-brace-format',
245
+    )
246
+    DERIVEPASSPHRASE_VAULT_01 = _prepare_translatable(
247
+        msg="""
248
+        Derive a passphrase using the vault derivation scheme.
249
+        """,
250
+        comments='',
251
+        context='help text (long form)',
252
+    )
253
+    DERIVEPASSPHRASE_VAULT_02 = _prepare_translatable(
254
+        msg="""
255
+        If operating on global settings, or importing/exporting
256
+        settings, then {service_metavar!s} must be omitted.  Otherwise
257
+        it is required.
258
+        """,
259
+        comments='',
260
+        context='help text (long form)',
261
+        flags='python-brace-format',
262
+    )
263
+    DERIVEPASSPHRASE_VAULT_EPILOG_01 = _prepare_translatable(
264
+        msg="""
265
+        WARNING: There is NO WAY to retrieve the generated passphrases
266
+        if the master passphrase, the SSH key, or the exact passphrase
267
+        settings are lost, short of trying out all possible
268
+        combinations.  You are STRONGLY advised to keep independent
269
+        backups of the settings and the SSH key, if any.
270
+        """,
271
+        comments='',
272
+        context='help text (long form)',
273
+    )
274
+    DERIVEPASSPHRASE_VAULT_EPILOG_02 = _prepare_translatable(
275
+        msg="""
276
+        The configuration is NOT encrypted, and you are STRONGLY
277
+        discouraged from using a stored passphrase.
278
+        """,
279
+        comments='',
280
+        context='help text (long form)',
281
+    )
282
+    DEPRECATED_COMMAND_LABEL = _prepare_translatable(
283
+        msg='(Deprecated) {text}',
284
+        comments='',
285
+        context='help text (long form, label)',
286
+        flags='python-brace-format',
287
+    )
288
+    DEBUG_OPTION_HELP_TEXT = _prepare_translatable(
289
+        'also emit debug information (implies --verbose)',
290
+        comments='',
291
+        context='help text (option one-line description)',
292
+    )
293
+    EXPORT_VAULT_FORMAT_HELP_TEXT = _prepare_translatable(
294
+        comments=r"""
295
+        TRANSLATORS: The defaults_hint is the text in
296
+        EXPORT_VAULT_FORMAT_DEFAULTS_HELP_TEXT, the metavar is in
297
+        EXPORT_VAULT_FORMAT_METAVAR_FMT.
298
+        """,
299
+        msg=r"""
300
+        try the following storage format {metavar!s}; may be
301
+        specified multiple times, formats will be tried in order
302
+        {defaults_hint!s}
303
+        """,
304
+        context='help text (option one-line description)',
305
+        flags='python-brace-format',
306
+    )
307
+    EXPORT_VAULT_FORMAT_DEFAULTS_HELP_TEXT = _prepare_translatable(
308
+        comments=r"""
309
+        TRANSLATORS: See EXPORT_VAULT_FORMAT_HELP_TEXT.  The format
310
+        names/labels "v0.3", "v0.2" and "storeroom" should not be
311
+        translated.
312
+        """,
313
+        msg=r"""
314
+        (default: v0.3, v0.2, storeroom)
315
+        """,
316
+        context='help text (option one-line description)',
317
+    )
318
+    EXPORT_VAULT_KEY_HELP_TEXT = _prepare_translatable(
319
+        comments=r"""
320
+        TRANSLATORS: The defaults_hint is the text in
321
+        EXPORT_VAULT_KEY_DEFAULTS_HELP_TEXT, the metavar is in
322
+        EXPORT_VAULT_KEY_METAVAR_K.
323
+        """,
324
+        msg=r"""
325
+        use {metavar!s} as the storage master key {defaults_hint!s}
326
+        """,
327
+        context='help text (option one-line description)',
328
+        flags='python-brace-format',
329
+    )
330
+    EXPORT_VAULT_KEY_DEFAULTS_HELP_TEXT = _prepare_translatable(
331
+        comments=r"""
332
+        TRANSLATORS: See EXPORT_VAULT_KEY_HELP_TEXT.
333
+        """,
334
+        msg=r"""
335
+        (default: check the `VAULT_KEY`, `LOGNAME`, `USER`, or
336
+        `USERNAME` environment variables)
337
+        """,
338
+        context='help text (option one-line description)',
339
+    )
340
+    QUIET_OPTION_HELP_TEXT = _prepare_translatable(
341
+        'suppress even warnings, emit only errors',
342
+        comments='',
343
+        context='help text (option one-line description)',
344
+    )
345
+    VERBOSE_OPTION_HELP_TEXT = _prepare_translatable(
346
+        'emit extra/progress information to standard error',
347
+        comments='',
348
+        context='help text (option one-line description)',
349
+    )
129 350
 
130
-    @classmethod
131
-    def ErrMsg(  # noqa: N802
132
-        cls,
133
-        msg_template: ErrMsgTemplate,
134
-        args_dict: Mapping[str, Any] = types.MappingProxyType({}),
135
-        /,
136
-        **kwargs: Any,  # noqa: ANN401
137
-    ) -> Self:
138
-        return cls(msg_template.value, {**args_dict, **kwargs})
351
+    DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT = _prepare_translatable(
352
+        msg='prompt for a master passphrase',
353
+        comments='',
354
+        context='help text (option one-line description)',
355
+    )
356
+    DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT = _prepare_translatable(
357
+        msg='select a suitable SSH key from the SSH agent',
358
+        comments='',
359
+        context='help text (option one-line description)',
360
+    )
361
+    DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT = _prepare_translatable(
362
+        comments=r"""
363
+        TRANSLATORS: The metavar is specified in
364
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
365
+        """,
366
+        msg='ensure a passphrase length of {metavar!s} characters',
367
+        context='help text (option one-line description)',
368
+        flags='python-brace-format',
369
+    )
370
+    DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT = _prepare_translatable(
371
+        comments=r"""
372
+        TRANSLATORS: The metavar is specified in
373
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
374
+        """,
375
+        msg='forbid any run of {metavar!s} identical characters',
376
+        context='help text (option one-line description)',
377
+        flags='python-brace-format',
378
+    )
379
+    DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT = _prepare_translatable(
380
+        comments=r"""
381
+        TRANSLATORS: The metavar is specified in
382
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
383
+        """,
384
+        msg='ensure at least {metavar!s} lowercase characters',
385
+        context='help text (option one-line description)',
386
+        flags='python-brace-format',
387
+    )
388
+    DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT = _prepare_translatable(
389
+        comments=r"""
390
+        TRANSLATORS: The metavar is specified in
391
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
392
+        """,
393
+        msg='ensure at least {metavar!s} uppercase characters',
394
+        context='help text (option one-line description)',
395
+        flags='python-brace-format',
396
+    )
397
+    DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT = _prepare_translatable(
398
+        comments=r"""
399
+        TRANSLATORS: The metavar is specified in
400
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
401
+        """,
402
+        msg='ensure at least {metavar!s} digits',
403
+        context='help text (option one-line description)',
404
+        flags='python-brace-format',
405
+    )
406
+    DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT = _prepare_translatable(
407
+        comments=r"""
408
+        TRANSLATORS: The metavar is specified in
409
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
410
+        """,
411
+        msg='ensure at least {metavar!s} spaces',
412
+        context='help text (option one-line description)',
413
+        flags='python-brace-format',
414
+    )
415
+    DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT = _prepare_translatable(
416
+        comments=r"""
417
+        TRANSLATORS: The metavar is specified in
418
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
419
+        """,
420
+        msg='ensure at least {metavar!s} "-" or "_" characters',
421
+        context='help text (option one-line description)',
422
+        flags='python-brace-format',
423
+    )
424
+    DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT = _prepare_translatable(
425
+        comments=r"""
426
+        TRANSLATORS: The metavar is specified in
427
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
428
+        """,
429
+        msg='ensure at least {metavar!s} symbol characters',
430
+        context='help text (option one-line description)',
431
+        flags='python-brace-format',
432
+    )
433
+
434
+    DERIVEPASSPHRASE_VAULT_NOTES_HELP_TEXT = _prepare_translatable(
435
+        msg='spawn an editor to edit notes for {service_metavar!s}',
436
+        comments='',
437
+        context='help text (option one-line description)',
438
+        flags='python-brace-format',
439
+    )
440
+    DERIVEPASSPHRASE_VAULT_CONFIG_HELP_TEXT = _prepare_translatable(
441
+        msg='save the given settings for {service_metavar!s}, or global',
442
+        comments='',
443
+        context='help text (option one-line description)',
444
+        flags='python-brace-format',
445
+    )
446
+    DERIVEPASSPHRASE_VAULT_DELETE_HELP_TEXT = _prepare_translatable(
447
+        msg='delete the settings for {service_metavar!s}',
448
+        comments='',
449
+        context='help text (option one-line description)',
450
+        flags='python-brace-format',
451
+    )
452
+    DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT = _prepare_translatable(
453
+        msg='delete the global settings',
454
+        comments='',
455
+        context='help text (option one-line description)',
456
+    )
457
+    DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT = _prepare_translatable(
458
+        msg='delete all settings',
459
+        comments='',
460
+        context='help text (option one-line description)',
461
+    )
462
+    DERIVEPASSPHRASE_VAULT_EXPORT_HELP_TEXT = _prepare_translatable(
463
+        comments="""
464
+        TRANSLATORS: The metavar is specified in
465
+        STORAGE_MANAGEMENT_METAVAR_SERVICE.
466
+        """,
467
+        msg='export all saved settings to {metavar!s}',
468
+        context='help text (option one-line description)',
469
+        flags='python-brace-format',
470
+    )
471
+    DERIVEPASSPHRASE_VAULT_IMPORT_HELP_TEXT = _prepare_translatable(
472
+        comments="""
473
+        TRANSLATORS: The metavar is specified in
474
+        STORAGE_MANAGEMENT_METAVAR_SERVICE.
475
+        """,
476
+        msg='import saved settings from {metavar!s}',
477
+        context='help text (option one-line description)',
478
+        flags='python-brace-format',
479
+    )
480
+    DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT = _prepare_translatable(
481
+        comments="""
482
+        TRANSLATORS: The corresponding option is displayed as
483
+        "--overwrite-existing / --merge-existing", so you may want to
484
+        hint that the default (merge) is the second of those options.
485
+        """,
486
+        msg='overwrite or merge (default) the existing configuration',
487
+        context='help text (option one-line description)',
488
+        flags='python-brace-format',
489
+    )
490
+    DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT = _prepare_translatable(
491
+        comments="""
492
+        TRANSLATORS: The corresponding option is displayed as
493
+        "--unset=phrase|key|...|symbol", so the "given setting" is
494
+        referring to "phrase", "key", "lower", ..., or "symbol",
495
+        respectively.  "with --config" here means that the user must
496
+        also specify "--config" for this option to have any effect.
497
+        """,
498
+        msg="""
499
+        with --config, also unsets the given setting; may be specified
500
+        multiple times
501
+        """,
502
+        context='help text (option one-line description)',
503
+    )
504
+    DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT = _prepare_translatable(
505
+        comments="""
506
+        TRANSLATORS: The corresponding option is displayed as
507
+        "--export-as=json|sh", so json refers to the JSON format
508
+        (default) and sh refers to the POSIX sh format.
509
+        """,
510
+        msg='when exporting, export as JSON (default) or POSIX sh',
511
+        context='help text (option one-line description)',
512
+    )
513
+
514
+    EXPORT_VAULT_FORMAT_METAVAR_FMT = _prepare_translatable(
515
+        msg='FMT',
516
+        comments='',
517
+        context='help text, metavar (export vault subcommand)',
518
+    )
519
+    EXPORT_VAULT_KEY_METAVAR_K = _prepare_translatable(
520
+        comments=r"""
521
+        TRANSLATORS: See EXPORT_VAULT_KEY_HELP_TEXT.
522
+        """,
523
+        msg='K',
524
+        context='help text, metavar (export vault subcommand)',
525
+    )
526
+    EXPORT_VAULT_METAVAR_PATH = _prepare_translatable(
527
+        comments=r"""
528
+        TRANSLATORS: This metavar is also used in multiple one-line help
529
+        texts, as "path_metavar".
530
+        """,
531
+        msg='PATH',
532
+        context='help text, metavar (export vault subcommand)',
533
+    )
534
+    PASSPHRASE_GENERATION_METAVAR_NUMBER = _prepare_translatable(
535
+        comments=r"""
536
+        TRANSLATORS: This metavar is also used in a matching epilog.
537
+        """,
538
+        msg='NUMBER',
539
+        context='help text, metavar (passphrase generation group)',
540
+    )
541
+    STORAGE_MANAGEMENT_METAVAR_PATH = _prepare_translatable(
542
+        comments=r"""
543
+        TRANSLATORS: This metavar is also used in multiple one-line help
544
+        texts.
545
+        """,
546
+        msg='NUMBER',
547
+        context='help text, metavar (storage management group)',
548
+    )
549
+    VAULT_METAVAR_SERVICE = _prepare_translatable(
550
+        comments=r"""
551
+        TRANSLATORS: This metavar is also used in multiple one-line help
552
+        texts, as "service_metavar".
553
+        """,
554
+        msg='SERVICE',
555
+        context='help text, metavar (vault subcommand)',
556
+    )
557
+    CONFIGURATION_EPILOG = _prepare_translatable(
558
+        'Use $VISUAL or $EDITOR to configure the spawned editor.',
559
+        comments='',
560
+        context='help text, option group epilog (configuration group)',
561
+    )
562
+    PASSPHRASE_GENERATION_EPILOG = _prepare_translatable(
563
+        comments=r"""
564
+        TRANSLATORS: The metavar is specified in
565
+        PASSPHRASE_GENERATION_METAVAR_NUMBER.
566
+        """,
567
+        msg=r"""
568
+        Use {metavar!s}=0 to exclude a character type from the output.
569
+        """,
570
+        context='help text, option group epilog (passphrase generation group)',
571
+        flags='python-brace-format',
572
+    )
573
+    STORAGE_MANAGEMENT_EPILOG = _prepare_translatable(
574
+        comments=r"""
575
+        TRANSLATORS: The metavar is specified in
576
+        STORAGE_MANAGEMENT_METAVAR_PATH.
577
+        """,
578
+        msg=r"""
579
+        Using "-" as {metavar!s} for standard input/standard output
580
+        is supported.
581
+        """,
582
+        context='help text, option group epilog (storage management group)',
583
+        flags='python-brace-format',
584
+    )
585
+    COMMANDS_LABEL = _prepare_translatable(
586
+        'Commands', comments='', context='help text, option group name'
587
+    )
588
+    COMPATIBILITY_OPTION_LABEL = _prepare_translatable(
589
+        'Compatibility and extension options',
590
+        comments='',
591
+        context='help text, option group name',
592
+    )
593
+    CONFIGURATION_LABEL = _prepare_translatable(
594
+        'Configuration', comments='', context='help text, option group name'
595
+    )
596
+    LOGGING_LABEL = _prepare_translatable(
597
+        'Logging', comments='', context='help text, option group name'
598
+    )
599
+    OPTIONS_LABEL = _prepare_translatable(
600
+        'Options', comments='', context='help text, option group name'
601
+    )
602
+    OTHER_OPTIONS_LABEL = _prepare_translatable(
603
+        'Other options', comments='', context='help text, option group name'
604
+    )
605
+    PASSPHRASE_GENERATION_LABEL = _prepare_translatable(
606
+        'Passphrase generation',
607
+        comments='',
608
+        context='help text, option group name',
609
+    )
610
+    STORAGE_MANAGEMENT_LABEL = _prepare_translatable(
611
+        'Storage management',
612
+        comments='',
613
+        context='help text, option group name',
614
+    )
615
+    CONFIRM_THIS_CHOICE_PROMPT_TEXT = _prepare_translatable(
616
+        comments=r"""
617
+        TRANSLATORS: There is no support for "yes" or "no" in other
618
+        languages than English, so it is advised that your translation
619
+        makes it clear that only the strings "y", "yes", "n" or "no" are
620
+        supported, even if the prompt becomes a bit longer.
621
+        """,
622
+        msg='Confirm this choice? (y/N)',
623
+        context='interactive prompt',
624
+    )
625
+    SUITABLE_SSH_KEYS_LABEL = _prepare_translatable(
626
+        comments=r"""
627
+        TRANSLATORS: This label is the heading of the list of suitable
628
+        SSH keys.
629
+        """,
630
+        msg='Suitable SSH keys:',
631
+        context='interactive prompt',
632
+    )
633
+    YOUR_SELECTION_PROMPT_TEXT = _prepare_translatable(
634
+        'Your selection? (1-{n}, leave empty to abort)',
635
+        comments='',
636
+        context='interactive prompt',
637
+        flags='python-brace-format',
638
+    )
139 639
 
140 640
 
141 641
 class InfoMsgTemplate(enum.Enum):
... ...
@@ -176,20 +676,25 @@ class InfoMsgTemplate(enum.Enum):
176 676
 
177 677
 class WarnMsgTemplate(enum.Enum):
178 678
     EMPTY_SERVICE_NOT_SUPPORTED = _prepare_translatable(
179
-        'An empty SERVICE is not supported by vault(1).  '
180
-        'For compatibility, this will be treated as if SERVICE was not '
181
-        'supplied, i.e., it will error out, or operate on global settings.',
182 679
         comments='',
680
+        msg="""
681
+        An empty {service_metavar!s} is not supported by vault(1).
682
+        For compatibility, this will be treated as if SERVICE was not
683
+        supplied, i.e., it will error out, or operate on global settings.
684
+        """,
183 685
         context='warning message',
686
+        flags='python-brace-format',
184 687
     )
185 688
     EMPTY_SERVICE_SETTINGS_INACCESSIBLE = _prepare_translatable(
186
-        f'An empty SERVICE is not supported by vault(1).  '
187
-        f'The empty-string service settings will be '
188
-        f'inaccessible and ineffective.  '
189
-        f'To ensure that vault(1) and {PROG_NAME!s} see the settings, '
190
-        f'move them into the "global" section.',
689
+        msg="""
690
+        An empty {service_metavar!s} is not supported by vault(1).
691
+        The empty-string service settings will be inaccessible and
692
+        ineffective.  To ensure that vault(1) and {PROG_NAME!s} see the
693
+        settings, move them into the "global" section.
694
+        """,
191 695
         comments='',
192 696
         context='warning message',
697
+        flags='python-brace-format',
193 698
     )
194 699
     FAILED_TO_MIGRATE_CONFIG = _prepare_translatable(
195 700
         comments=r"""
... ...
@@ -201,8 +706,10 @@ class WarnMsgTemplate(enum.Enum):
201 706
         flags='python-brace-format',
202 707
     )
203 708
     GLOBAL_PASSPHRASE_INEFFECTIVE = _prepare_translatable(
204
-        'Setting a global passphrase is ineffective '
205
-        'because a key is also set.',
709
+        msg=r"""
710
+        Setting a global passphrase is ineffective
711
+        because a key is also set.
712
+        """,
206 713
         comments='',
207 714
         context='warning message',
208 715
     )
... ...
@@ -219,11 +726,12 @@ class WarnMsgTemplate(enum.Enum):
219 726
         any other appropriate way to mark up emphasis of the word
220 727
         "displays".
221 728
         """,
222
-        msg='The {key!s} passphrase is not {form!s}-normalized.  '
223
-        'Its serialization as a byte string may not be what you '
224
-        'expect it to be, even if it *displays* correctly.  '
225
-        'Please make sure to double-check any derived '
226
-        'passphrases for unexpected results.',
729
+        msg=r"""
730
+        The {key!s} passphrase is not {form!s}-normalized.  Its
731
+        serialization as a byte string may not be what you expect it to
732
+        be, even if it *displays* correctly.  Please make sure to
733
+        double-check any derived passphrases for unexpected results.
734
+        """,
227 735
         context='warning message',
228 736
         flags='python-brace-format',
229 737
     )
... ...
@@ -232,8 +740,10 @@ class WarnMsgTemplate(enum.Enum):
232 740
         TRANSLATORS: The key that is set need not necessarily be set at
233 741
         the service level; it may be a global key as well.
234 742
         """,
235
-        msg='Setting a service passphrase is ineffective '
236
-        'because a key is also set: {service!s}.',
743
+        msg=r"""
744
+        Setting a service passphrase is ineffective because a key is
745
+        also set: {service!s}.
746
+        """,
237 747
         context='warning message',
238 748
         flags='python-brace-format',
239 749
     )
... ...
@@ -250,9 +760,11 @@ class WarnMsgTemplate(enum.Enum):
250 760
         flags='python-brace-format',
251 761
     )
252 762
     V01_STYLE_CONFIG = _prepare_translatable(
253
-        'Using deprecated v0.1-style config file {old!r}, '
254
-        'instead of v0.2-style {new!r}.  '
255
-        'Support for v0.1-style config filenames will be removed in v1.0.',
763
+        msg=r"""
764
+        Using deprecated v0.1-style config file {old!r}, instead of
765
+        v0.2-style {new!r}.  Support for v0.1-style config filenames
766
+        will be removed in v1.0.,
767
+        """,
256 768
         comments='',
257 769
         context='deprecation warning message',
258 770
         flags='python-brace-format',
... ...
@@ -264,14 +776,36 @@ class WarnMsgTemplate(enum.Enum):
264 776
         sub-subcommands, or sub-sub-subcommands, etc., which is what the
265 777
         "here" is supposed to indicate.
266 778
         """,
267
-        msg='A subcommand will be required here in v1.0.  '
268
-        'See --help for available subcommands.  '
269
-        'Defaulting to subcommand "vault".',
779
+        msg="""
780
+        A subcommand will be required here in v1.0.  See --help for
781
+        available subcommands.  Defaulting to subcommand "vault".
782
+        """,
270 783
         context='deprecation warning message',
271 784
     )
272 785
 
273 786
 
274 787
 class ErrMsgTemplate(enum.Enum):
788
+    AGENT_REFUSED_LIST_KEYS = _prepare_translatable(
789
+        comments=r"""
790
+        TRANSLATORS: "loaded keys" being keys loaded into the agent.
791
+        """,
792
+        msg="""
793
+        The SSH agent failed to or refused to supply a list of loaded keys.
794
+        """,
795
+        context='error message',
796
+    )
797
+    AGENT_REFUSED_SIGNATURE = _prepare_translatable(
798
+        comments=r"""
799
+        TRANSLATORS: The message to be signed is the vault UUID, but
800
+        there's no space to explain that here, so ideally the error
801
+        message does not go into detail.
802
+        """,
803
+        msg="""
804
+        The SSH agent failed to or refused to issue a signature with the
805
+        selected key, necessary for deriving a service passphrase.
806
+        """,
807
+        context='error message',
808
+    )
275 809
     CANNOT_CONNECT_TO_AGENT = _prepare_translatable(
276 810
         comments=r"""
277 811
         TRANSLATORS: The error message is usually supplied by the
... ...
@@ -332,8 +866,10 @@ class ErrMsgTemplate(enum.Enum):
332 866
         warning message potentially multiple times, and this error
333 867
         message at the very bottom.
334 868
         """,
335
-        msg='Cannot parse {path!r} as a valid vault-native '
336
-        'configuration file/directory.',
869
+        msg=r"""
870
+        Cannot parse {path!r} as a valid vault-native configuration
871
+        file/directory.
872
+        """,
337 873
         context='error message',
338 874
         flags='python-brace-format',
339 875
     )
... ...
@@ -346,15 +882,42 @@ class ErrMsgTemplate(enum.Enum):
346 882
         context='error message',
347 883
         flags='python-brace-format',
348 884
     )
885
+    CANNOT_UNDERSTAND_AGENT = _prepare_translatable(
886
+        comments=r"""
887
+        TRANSLATORS: This error message is used whenever we cannot make
888
+        any sense of a response from the SSH agent because the response
889
+        is ill-formed (truncated, improperly encoded, etc.) or otherwise
890
+        violates the communications protocol.  Well-formed responses
891
+        that adhere to the protocol, even if they indicate that the
892
+        requested operation failed, are handled with a different error
893
+        message.
894
+        """,
895
+        msg="""
896
+        Cannot understand the SSH agent's response because it violates
897
+        the communications protocol.
898
+        """,
899
+    )
349 900
     CANNOT_UPDATE_SETTINGS_NO_SETTINGS = _prepare_translatable(
350
-        msg='Cannot update {settings_type!s} settings '
351
-        'without any given settings.  '
352
-        'You must specify at least one of --lower, ..., '
353
-        '--symbol, or --phrase or --key.',
901
+        msg=r"""
902
+        Cannot update {settings_type!s} settings without any given
903
+        settings.  You must specify at least one of --lower, ...,
904
+        --symbol, or --phrase or --key.
905
+        """,
354 906
         comments='',
355 907
         context='error message',
356 908
         flags='python-brace-format',
357 909
     )
910
+    INVALID_USER_CONFIG = _prepare_translatable(
911
+        comments=r"""
912
+        TRANSLATORS: The error message is usually supplied by the
913
+        operating system, e.g. ENOENT/"No such file or directory".
914
+        """,
915
+        msg=r"""
916
+        The user configuration file is invalid.  {error!s}: {filename!r}.
917
+        """,
918
+        context='error message',
919
+        flags='python-brace-format',
920
+    )
358 921
     INVALID_VAULT_CONFIG = _prepare_translatable(
359 922
         comments=r"""
360 923
         TRANSLATORS: This error message is a reaction to a validator
... ...
@@ -373,14 +936,18 @@ class ErrMsgTemplate(enum.Enum):
373 936
         flags='python-brace-format',
374 937
     )
375 938
     NO_AF_UNIX = _prepare_translatable(
376
-        'Cannot connect to an SSH agent because this Python version '
377
-        'does not support UNIX domain sockets.',
939
+        msg=r"""
940
+        Cannot connect to an SSH agent because this Python version does
941
+        not support UNIX domain sockets.
942
+        """,
378 943
         comments='',
379 944
         context='error message',
380 945
     )
381 946
     NO_KEY_OR_PHRASE = _prepare_translatable(
382
-        'No passphrase or key was given in the configuration.  '
383
-        'In this case, the --phrase or --key argument is required.',
947
+        msg=r"""
948
+        No passphrase or key was given in the configuration.  In this
949
+        case, the --phrase or --key argument is required.
950
+        """,
384 951
         comments='',
385 952
         context='error message',
386 953
     )
... ...
@@ -389,6 +956,14 @@ class ErrMsgTemplate(enum.Enum):
389 956
         comments='',
390 957
         context='error message',
391 958
     )
959
+    NO_SUITABLE_SSH_KEYS = _prepare_translatable(
960
+        msg="""
961
+        The SSH agent contains no keys suitable for {PROG_NAME!s}.
962
+        """,  # noqa: RUF027
963
+        comments='',
964
+        context='error message',
965
+        flags='python-brace-format',
966
+    )
392 967
     PARAMS_MUTUALLY_EXCLUSIVE = _prepare_translatable(
393 968
         comments=r"""
394 969
         TRANSLATORS: The params are long-form command-line option names.
... ...
@@ -401,37 +976,48 @@ class ErrMsgTemplate(enum.Enum):
401 976
     PARAMS_NEEDS_SERVICE_OR_CONFIG = _prepare_translatable(
402 977
         comments=r"""
403 978
         TRANSLATORS: The param is a long-form command-line option name,
404
-        and "SERVICE" is the command-line argument for the (sometimes
405
-        optional) service name.
979
+        and the metavar is given in VAULT_METAVAR_SERVICE.
406 980
         """,
407
-        msg='{param!s} requires a SERVICE or --config.',
981
+        msg='{param!s} requires a {service_metavar!s} or --config.',
408 982
         context='error message',
409 983
         flags='python-brace-format',
410 984
     )
411 985
     PARAMS_NEEDS_SERVICE = _prepare_translatable(
412 986
         comments=r"""
413 987
         TRANSLATORS: The param is a long-form command-line option name,
414
-        and "SERVICE" is the command-line argument for the (sometimes
415
-        optional) service name.
988
+        and the metavar is given in VAULT_METAVAR_SERVICE.
416 989
         """,
417
-        msg='{param!s} requires a SERVICE.',
990
+        msg='{param!s} requires a {service_metavar!s}.',
418 991
         context='error message',
419 992
         flags='python-brace-format',
420 993
     )
421 994
     PARAMS_NO_SERVICE = _prepare_translatable(
422 995
         comments=r"""
423 996
         TRANSLATORS: The param is a long-form command-line option name,
424
-        and "SERVICE" is the command-line argument for the (sometimes
425
-        optional) service name.
997
+        and the metavar is given in VAULT_METAVAR_SERVICE.
426 998
         """,
427
-        msg='{param!s} does not take a SERVICE argument.',
999
+        msg='{param!s} does not take a {service_metavar!s} argument.',
428 1000
         context='error message',
429 1001
         flags='python-brace-format',
430 1002
     )
431 1003
     SERVICE_REQUIRED = _prepare_translatable(
432
-        'Generating a passphrase requires a SERVICE.',
433
-        comments='',
1004
+        comments=r"""
1005
+        TRANSLATORS: The metavar is given in VAULT_METAVAR_SERVICE.
1006
+        """,
1007
+        msg='Deriving a passphrase requires a {service_metavar!s}.',
1008
+        context='error message',
1009
+        flags='python-brace-format',
1010
+    )
1011
+    SET_AND_UNSET_SAME_SETTING = _prepare_translatable(
1012
+        comments=r"""
1013
+        TRANSLATORS: The rephrasing "Attempted to unset and set the same
1014
+        setting (--unset={setting!s} --{setting!s}=...) at the same
1015
+        time." may or may not be more suitable as a basis for
1016
+        translation instead.
1017
+        """,
1018
+        msg='Attempted to unset and set --{setting!s} at the same time.',
434 1019
         context='error message',
1020
+        flags='python-brace-format',
435 1021
     )
436 1022
     SSH_KEY_NOT_LOADED = _prepare_translatable(
437 1023
         'The requested SSH key is not loaded into the agent.',
... ...
@@ -453,13 +1039,3 @@ class ErrMsgTemplate(enum.Enum):
453 1039
         comments='',
454 1040
         context='error message',
455 1041
     )
456
-    USER_CONFIG_INVALID = _prepare_translatable(
457
-        comments=r"""
458
-        TRANSLATORS: The error message is usually supplied by the
459
-        operating system, e.g. ENOENT/"No such file or directory".
460
-        """,
461
-        msg='The user configuration file is invalid.  '
462
-        '{error!s}: {filename!r}.',
463
-        context='error message',
464
-        flags='python-brace-format',
465
-    )
466 1042